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
@@ -23,25 +23,30 @@ module RailsAiContext
23
23
  # a dump that parses to zero tables falls through to the next source.
24
24
  def self.for(root)
25
25
  root = root.to_s
26
+ # One parse per run however many introspectors ask.
27
+ RailsAiContext::RunCache.fetch([ :schema_reader, root ]) { choose(root) }
28
+ end
26
29
 
27
- schema_rb = File.join(root, "db", "schema.rb")
28
- if File.exist?(schema_rb)
29
- reader = new(schema_rb, pk_type: SchemaConventions.implicit_pk_type(root, schema_rb))
30
- return reader.with_source(:schema_rb) if reader.tables.any?
31
- end
30
+ def self.choose(root)
31
+ return from_tables({}, source: :none, path: nil) if RailsAiContext::AppKind.sequel_schema(root)
32
+
33
+ candidates = SchemaDumpPath.candidates(root)
34
+ candidates.each do |format, path|
35
+ next unless File.exist?(path)
32
36
 
33
- structure = File.join(root, "db", "structure.sql")
34
- if File.exist?(structure)
35
- content = RailsAiContext::SafeFile.read(structure, max_size: RailsAiContext.configuration.max_schema_file_size)
36
- if content
37
- parsed = StructureSqlReader.parse(content)
38
- return from_tables(parsed[:tables], source: :structure_sql, path: structure) if parsed[:tables].any?
37
+ if format == :ruby
38
+ reader = new(path, pk_type: SchemaConventions.implicit_pk_type(root))
39
+ return reader.with_source(:schema_rb) if reader.tables.any?
40
+ else
41
+ content = RailsAiContext::SafeFile.read(path, max_size: RailsAiContext.configuration.max_schema_file_size)
42
+ parsed = content && StructureSqlReader.parse(content)
43
+ return from_tables(parsed[:tables], source: :structure_sql, path: path, views: parsed[:views]) if parsed && parsed[:tables].any?
39
44
  end
40
45
  end
41
46
 
42
47
  migrate_dirs = MigrationReplay.migration_dirs(root)
43
- if MigrationReplay.migration_files(migrate_dirs).any?
44
- pk_type = SchemaConventions.implicit_pk_type(root, schema_rb)
48
+ if MigrationReplay.migration_files(migrate_dirs, root: root).any?
49
+ pk_type = SchemaConventions.implicit_pk_type(root)
45
50
  return from_tables(MigrationReplay.tables(migrate_dirs, pk_type: pk_type, root: root),
46
51
  source: :migrations, path: migrate_dirs.first)
47
52
  end
@@ -51,9 +56,9 @@ module RailsAiContext
51
56
 
52
57
  # A reader over an already-parsed tables hash, for the sources that do
53
58
  # not go through the schema.rb event fold.
54
- def self.from_tables(tables, source:, path:)
59
+ def self.from_tables(tables, source:, path:, views: {})
55
60
  reader = allocate
56
- reader.send(:initialize_from_tables, tables, source, path)
61
+ reader.send(:initialize_from_tables, tables, source, path, views)
57
62
  reader
58
63
  end
59
64
 
@@ -89,11 +94,31 @@ module RailsAiContext
89
94
  parse[:enums]
90
95
  end
91
96
 
92
- # @return [Array<Hash>] { table:, expression: } per declared constraint
97
+ # @return [Array<Hash>] { table:, name:, expression: } per declared constraint, name when given
93
98
  def check_constraints
94
99
  parse[:check_constraints]
95
100
  end
96
101
 
102
+ # @return [Array<String>] the extensions the dump enables
103
+ def extensions
104
+ parse[:extensions]
105
+ end
106
+
107
+ # @return [Hash] view name => { materialized:, sql: }
108
+ def views
109
+ parse[:views]
110
+ end
111
+
112
+ # @return [Hash] virtual table name => { module:, arguments: }
113
+ def virtual_tables
114
+ parse[:virtual_tables]
115
+ end
116
+
117
+ # @return [Hash] table name => why the dumper wrote a comment in its place
118
+ def not_dumped
119
+ parse[:not_dumped]
120
+ end
121
+
97
122
  # Declared defaults for one table, as source text. Callers report these
98
123
  # verbatim when the live database returns no default.
99
124
  def defaults_for(table)
@@ -114,7 +139,7 @@ module RailsAiContext
114
139
 
115
140
  attr_reader :path
116
141
 
117
- def initialize_from_tables(tables, source, path)
142
+ def initialize_from_tables(tables, source, path, views)
118
143
  @path = path
119
144
  @pk_type = nil
120
145
  @source = source
@@ -123,10 +148,15 @@ module RailsAiContext
123
148
  # structure.sql and the replay keep from_table/to_table on the table;
124
149
  # every reader answers the schema.rb shape, so no consumer checks both.
125
150
  foreign_keys: tables.flat_map { |_name, t| t[:foreign_keys] || [] }.map do |fk|
126
- { from: fk[:from_table], to: fk[:to_table], column: fk[:column], primary_key: fk[:primary_key] }.compact
151
+ { from: fk[:from_table], to: fk[:to_table], column: fk[:column], primary_key: fk[:primary_key],
152
+ on_delete: fk[:on_delete], on_update: fk[:on_update] }.compact
127
153
  end,
128
154
  enums: [],
129
- check_constraints: []
155
+ check_constraints: [],
156
+ extensions: [],
157
+ views: views,
158
+ virtual_tables: {},
159
+ not_dumped: {}
130
160
  }
131
161
  end
132
162
 
@@ -139,7 +169,7 @@ module RailsAiContext
139
169
  end
140
170
 
141
171
  def empty_schema
142
- { tables: {}, foreign_keys: [], enums: [], check_constraints: [] }
172
+ { tables: {}, foreign_keys: [], enums: [], check_constraints: [], extensions: [], views: {}, virtual_tables: {}, not_dumped: {} }
143
173
  end
144
174
 
145
175
  def build
@@ -213,18 +243,27 @@ module RailsAiContext
213
243
  when :unread_call
214
244
  SchemaConventions.note_unread_call(schema[:tables][current], event[:name]) if current
215
245
  when :add_index
216
- schema[:tables][event[:table]]&.dig(:indexes)&.push(index_entry(event))
246
+ target = schema[:tables][event[:table]] || schema[:views][event[:table]]
247
+ (target[:indexes] ||= []) << index_entry(event) if target
217
248
  when :foreign_key
218
- schema[:foreign_keys] << {
219
- from: event[:from], to: event[:to],
220
- column: event[:column], primary_key: event[:primary_key]
221
- }.compact
249
+ schema[:foreign_keys] << event.slice(:from, :to, :column, :primary_key, *SchemaConventions::FOREIGN_KEY_OPTIONS)
250
+ when :unique_constraint
251
+ table = schema[:tables][current] if current
252
+ (table[:unique_constraints] ||= []) << event.slice(:columns, :options) if table
253
+ when :extension
254
+ schema[:extensions] << event[:name]
222
255
  when :enum
223
256
  schema[:enums] << { name: event[:name], values: event[:values] }
224
257
  when :check_constraint
225
- schema[:check_constraints] << { table: current, expression: event[:expression] } if current
258
+ schema[:check_constraints] << { table: current, **event.slice(:name, :expression) } if current
226
259
  when :add_check_constraint
227
- schema[:check_constraints] << { table: event[:table], expression: event[:expression] }
260
+ schema[:check_constraints] << event.slice(:table, :name, :expression)
261
+ when :view
262
+ schema[:views][event[:name]] = { materialized: event[:materialized], sql: event[:sql] }
263
+ when :virtual_table
264
+ schema[:virtual_tables][event[:name]] = event.slice(:module, :arguments)
265
+ when :not_dumped
266
+ schema[:not_dumped][event[:table]] = event[:reason]
228
267
  end
229
268
 
230
269
  current
@@ -236,8 +275,9 @@ module RailsAiContext
236
275
  name: column_name(event),
237
276
  type: event[:column_type],
238
277
  default: default_for(event, options),
239
- options: options
240
- }
278
+ options: options,
279
+ virtual: event[:virtual]
280
+ }.compact
241
281
  end
242
282
 
243
283
  def index_entry(event)
@@ -2,28 +2,32 @@
2
2
 
3
3
  module RailsAiContext
4
4
  module Introspectors
5
- # Which classes under app/services are services and which are only a base. The tool's
5
+ # Which classes under the service roots are services and which are only a base. The tool's
6
6
  # listing and the generated Services line both ask here, so they name one set.
7
7
  module ServiceClasses
8
+ # active_interaction recommends app/interactions, and interactor-rails
9
+ # autoloads and generates into app/interactors.
10
+ ROOTS = %w[app/services app/interactions app/interactors].freeze
11
+
8
12
  module_function
9
13
 
10
14
  # The declared constant and its superclass from one parse; the path fills in a
11
15
  # namespace the source leaves out.
12
16
  #
13
- # @return [Array(String, String)] name, superclass
17
+ # @return [Array(String, String, Array<String>)] name, superclass, the nesting it is read in
14
18
  def declaration(source, path_name)
15
- own = DeclaredConstant.declaration_for(DeclaredConstant.declarations(source), path_name)
16
- [ own&.name || DeclaredConstant.resolve(source, path_name), own&.superclass ]
19
+ own = DeclaredConstant.declaration_for(DeclaredConstant.declarations(source, path_name: path_name), path_name)
20
+ [ own&.name || DeclaredConstant.resolve(source, path_name), own&.superclass, own&.nesting ]
17
21
  end
18
22
 
19
- # The bases among the given [name, superclass] pairs: a Base-named class
23
+ # The bases among the given [name, superclass, nesting] triples: a Base-named class
20
24
  # something else in the list inherits from, or one of Rails' own.
21
- def abstract_names(pairs)
22
- named = pairs.map(&:first).select { |name| SuperclassChain.abstract_base_name?(name) }
25
+ def abstract_names(declared)
26
+ named = declared.map(&:first).select { |name| SuperclassChain.abstract_base_name?(name) }
23
27
  return [] if named.empty?
24
28
 
25
- inherited = pairs.filter_map do |name, superclass|
26
- SuperclassChain.resolve_in_scope(name, superclass) { |candidate| candidate if named.include?(candidate) }
29
+ inherited = declared.filter_map do |name, superclass, nesting|
30
+ SuperclassChain.resolve_in_scope(name, superclass, nesting: nesting) { |candidate| candidate if named.include?(candidate) }
27
31
  end
28
32
  named.select { |name| SuperclassChain.abstract_base?(name, inherited: inherited.include?(name)) }.uniq.sort
29
33
  end
@@ -82,14 +86,16 @@ module RailsAiContext
82
86
  pairs = []
83
87
  modules = []
84
88
  lookup = SuperclassChain.lookup_for(root)
85
- SourceScan.each(root, kind: "app/services") do |record|
86
- next if concern?(record.file, record.source)
89
+ ROOTS.each do |kind|
90
+ SourceScan.each(root, kind: kind) do |record|
91
+ next if concern?(record.file, record.source)
87
92
 
88
- name, superclass = declaration(record.source, record.path_name)
89
- next if mailer?(record.source, name, superclass, lookup)
93
+ name, superclass, nesting = declaration(record.source, record.path_name)
94
+ next if mailer?(record.source, name, superclass, lookup)
90
95
 
91
- pairs << [ name, superclass ]
92
- modules << name if entryless_module?(record.source, name)
96
+ pairs << [ name, superclass, nesting ]
97
+ modules << name if entryless_module?(record.source, name)
98
+ end
93
99
  end
94
100
  left_out = abstract_names(pairs) + mixed_in(root, modules)
95
101
  pairs.map(&:first).reject { |name| left_out.include?(name) }.uniq.sort
@@ -19,7 +19,7 @@ module RailsAiContext
19
19
  # framework, the standard library, and the app's record base.
20
20
  IGNORED = %w[
21
21
  Rails ActiveRecord ApplicationRecord File Dir ENV String Integer Float
22
- Array Hash Set Time Date DateTime URI Regexp
22
+ Array Hash Set Time Date DateTime URI Regexp Class
23
23
  ].freeze
24
24
 
25
25
  # `::Foo::Bar` and `Foo::Bar` are one constant; the root operator is not part of the name.
@@ -49,8 +49,9 @@ module RailsAiContext
49
49
  end
50
50
 
51
51
  # Walk a file with a custom listener map. Returns { key => results_array }.
52
- def self.walk(path, listener_map = LISTENER_MAP)
53
- result = AstCache.parse(path)
52
+ # A caller that already read the file passes `source`, so its other reads share the parse.
53
+ def self.walk(path, listener_map = LISTENER_MAP, source: nil)
54
+ result = source ? AstCache.parse_string(source) : AstCache.parse(path)
54
55
  walk_dispatch(result, listener_map)
55
56
  end
56
57
 
@@ -60,6 +61,20 @@ module RailsAiContext
60
61
  walk_dispatch(result, listener_map)
61
62
  end
62
63
 
64
+ # The calls a class body makes itself, out of what one walk found: a call
65
+ # inside a `def` runs only when the method is called.
66
+ def self.outside_defs(calls, methods)
67
+ bodies = methods.respond_to?(:def_spans) ? methods.def_spans : Array(methods).filter_map { |m| m[:offset]...m[:end_offset] if m[:offset] && m[:end_offset] }
68
+ Array(calls).reject { |call| call[:offset] && bodies.any? { |range| range.cover?(call[:offset]) } }
69
+ end
70
+
71
+ # The calls a class body makes itself, out of a walk that ran MethodsListener as `methods:` and
72
+ # NestedConstantsListener as `nested:`: a class or module it nests declares only for itself.
73
+ def self.class_level(calls, walked)
74
+ nested = Array(walked[:nested])
75
+ outside_defs(calls, walked[:methods]).reject { |call| call[:offset] && nested.any? { |range| range.cover?(call[:offset]) } }
76
+ end
77
+
63
78
  # Walk a parse result the caller already holds, so a second reader of the
64
79
  # same source does not parse it again.
65
80
  def self.walk_dispatch(parse_result, listener_map)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module RailsAiContext
4
6
  module Introspectors
5
7
  # One walk over a kind of app source: every directory PathResolver
@@ -25,46 +27,190 @@ module RailsAiContext
25
27
  end.each(&block)
26
28
  end
27
29
 
28
- def scan(root, kind, skip_concerns)
30
+ def scan(root, kind, skip_concerns, &block)
29
31
  root = root.to_s
30
32
  real_root = File.realpath(root)
31
33
  PathResolver.dirs_for(root, kind).each do |dir|
32
- real_dir = File.realpath(dir)
33
- Dir.glob(File.join(dir, "**", "*.rb")).sort.each do |path|
34
- relative_to_dir = path.delete_prefix(dir + File::SEPARATOR)
35
- next if skip_concerns && relative_to_dir.start_with?("concerns/")
34
+ scan_dir(dir, root, real_root, skip_concerns, &block)
35
+ end
36
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
37
+ nil
38
+ end
36
39
 
37
- real = File.realpath(path)
38
- next unless SafePath.contained?(real, real_dir)
40
+ def scan_dir(dir, root, real_root, skip_concerns)
41
+ real_dir = File.realpath(dir)
42
+ ruby_files(dir, real_dir, [ real_dir, real_root ], Set.new).sort_by(&:first).each do |path, real|
43
+ relative_to_dir = path.delete_prefix(dir + File::SEPARATOR)
44
+ next if skip_concerns && relative_to_dir.start_with?("concerns/")
45
+ next unless within?(real, real_dir, real_root)
46
+
47
+ path_name = relative_to_dir.sub(/\.rb\z/, "").split("/").map(&:camelize).join("::")
48
+ yield Record.new(path: real, file: relative_file(path, real, root, real_root), path_name: path_name, source: nil)
49
+ end
50
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
51
+ nil
52
+ end
53
+
54
+ # Zeitwerk follows symlinks, so the walk does too, as far as the app's
55
+ # own tree; a directory reached twice (a link back up) is walked once.
56
+ # Linked directories wait until the real tree is done, so a directory
57
+ # both spell is named by its real path whatever order the disk lists.
58
+ # Each entry is [path as spelled, real path].
59
+ def ruby_files(dir, real_dir, bounds, visited)
60
+ pending = [ [ dir, real_dir ] ]
61
+ found = []
62
+ found.concat(walk_dir(*pending.shift, bounds, visited, pending)) while pending.any?
63
+ found
64
+ end
65
+
66
+ # One lstat per entry: below a real directory only a link needs a realpath.
67
+ def walk_dir(dir, real_dir, bounds, visited, links)
68
+ return [] unless visited.add?(real_dir)
69
+
70
+ Dir.children(dir).sort.flat_map do |name|
71
+ next [] if name.start_with?(".")
39
72
 
40
- path_name = relative_to_dir.sub(/\.rb\z/, "").split("/").map(&:camelize).join("::")
41
- yield Record.new(path: real, file: relative_file(path, real, root, real_root), path_name: path_name, source: nil)
42
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
43
- next
73
+ path = File.join(dir, name)
74
+ stat = File.lstat(path)
75
+ if stat.symlink?
76
+ real = File.realpath(path)
77
+ if !File.directory?(real)
78
+ name.end_with?(".rb") ? [ [ path, real ] ] : []
79
+ else
80
+ links << [ path, real ] if within?(real, *bounds)
81
+ []
82
+ end
83
+ elsif stat.directory?
84
+ walk_dir(path, File.join(real_dir, name), bounds, visited, links)
85
+ else
86
+ name.end_with?(".rb") ? [ [ path, File.join(real_dir, name) ] ] : []
44
87
  end
45
88
  rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
46
- next
89
+ []
47
90
  end
48
91
  rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
49
- nil
92
+ []
93
+ end
94
+
95
+ def within?(real, real_dir, real_root)
96
+ SafePath.contained?(real, real_dir) || SafePath.contained?(real, real_root)
97
+ end
98
+
99
+ # The model directories plus the classes elsewhere that could be a model.
100
+ # Only the model listing wants the second half: a count or a per-model
101
+ # read of app/models would take in every service with a superclass.
102
+ # A booted caller passes `base_model`, called with the names a superclass the scan does not
103
+ # know resolves to, innermost scope first, since a gem or initializer can define it.
104
+ def model_paths(root, base_model: nil, &block)
105
+ return enum_for(:model_paths, root, base_model: base_model) unless block
106
+
107
+ RunCache.fetch([ :source_scan_models, root.to_s, !base_model.nil? ]) do
108
+ found = paths(root, kind: "app/models", skip_concerns: false).to_a
109
+ found + extra_model_candidates(root.to_s, found, base_model)
110
+ end.each(&block)
111
+ end
112
+
113
+ CLASS_WITH_SUPERCLASS = /class[^\S\n]+([\w:]+)[^\S\n]*<[^\S\n]*([\w:]+)/
114
+ MODEL_BASES = %w[ActiveRecord::Base ApplicationRecord].freeze
115
+
116
+ # A model can live in any autoload root; a class there is kept when its superclass is a model base,
117
+ # a model or a class already kept, so a thousand services are not parsed.
118
+ def extra_model_candidates(root, model_records, base_model = nil)
119
+ pending, declared = RunCache.fetch([ :source_scan_model_declarations, root ]) { extra_model_declarations(root, File.realpath(root)) }
120
+ known = model_records.to_set(&:path_name).merge(MODEL_BASES)
121
+ declared = declared | known
122
+ loaded = Hash.new { |cache, candidates| cache[candidates] = base_model ? base_model.call(candidates) : false }
123
+ kept = Set.new
124
+ loop do
125
+ added = pending.select do |record, pairs|
126
+ !kept.include?(record) && pairs.any? do |name, base|
127
+ # Ruby takes the innermost lexical scope that declares the name;
128
+ # a compact `class Admin::Item < Base` has none, a nested one has the path's.
129
+ candidates = lookup(record.path_name, name, base).reverse
130
+ resolved = candidates.find { |candidate| declared.include?(candidate) }
131
+ resolved ? known.include?(resolved) : loaded[candidates]
132
+ end
133
+ end
134
+ break if added.empty?
135
+
136
+ added.each do |record, pairs|
137
+ kept << record
138
+ known.merge(pairs.map { |name, _| qualified(record.path_name, name) })
139
+ end
140
+ end
141
+ pending.filter_map { |record, _| record if kept.include?(record) }
142
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
143
+ []
144
+ end
145
+
146
+ # The names `base` can mean where `name` is declared in the file `path_name` names, outermost first.
147
+ def lookup(path_name, name, base = name)
148
+ return [ base.delete_prefix("::") ] if base.start_with?("::")
149
+
150
+ scopes = path_name.split("::")[0...-name.split("::").size]
151
+ (0..scopes.size).map { |depth| [ *scopes.first(depth), base ].join("::") }
152
+ end
153
+
154
+ # The constant a `class name` line in the file `path_name` names declares.
155
+ def qualified(path_name, name)
156
+ return name.delete_prefix("::") if name.start_with?("::")
157
+
158
+ name.include?("::") ? name : lookup(path_name, name).last
159
+ end
160
+
161
+ # The candidates, and every name the scanned files declare by path or by a superclassed class.
162
+ def extra_model_declarations(root, real_root)
163
+ seen = Set.new
164
+ declared = Set.new
165
+ ignored = PathResolver.ignored_dirs(root).map { |dir| PathResolver.root_key(dir) }
166
+ found = PathResolver.extra_model_roots(root).each_with_object([]) do |dir, records|
167
+ scan_dir(dir, root, real_root, true) do |record|
168
+ next unless seen.add?(record.path)
169
+ next if ignored.any? { |ignored_dir| SafePath.contained?(record.path, ignored_dir) }
170
+
171
+ declared << record.path_name
172
+ pairs = class_declarations(SafeFile.read(record.path).to_s)
173
+ next if pairs.empty?
174
+
175
+ records << [ record, pairs ]
176
+ pairs.each { |name, _| declared << qualified(record.path_name, name) }
177
+ end
178
+ end
179
+ [ found, declared ]
180
+ end
181
+
182
+ # [name, superclass] of each `class X < Y` that starts its line. A `^` anchor
183
+ # makes Onigmo try every offset, ten times slower over a service tree.
184
+ def class_declarations(source)
185
+ pairs = []
186
+ source.scan(CLASS_WITH_SUPERCLASS) do |name, base|
187
+ start = Regexp.last_match.begin(0)
188
+ line_start = start.zero? ? 0 : (source.rindex("\n", start - 1) || -1) + 1
189
+ pairs << [ name, base ] if source[line_start...start].match?(/\A[^\S\n]*\z/)
190
+ end
191
+ pairs
50
192
  end
51
193
 
52
- private_class_method :scan
194
+ private_class_method :scan, :scan_dir, :ruby_files, :walk_dir, :within?, :extra_model_candidates, :extra_model_declarations, :class_declarations, :lookup, :qualified
53
195
 
54
- def each(root, kind:, skip_concerns: true)
55
- return enum_for(:each, root, kind: kind, skip_concerns: skip_concerns) unless block_given?
196
+ # `kind: :models` reads model_paths: what model_details lists, not only app/models.
197
+ def each(root, kind:, skip_concerns: true, base_model: nil, &block)
198
+ return enum_for(:each, root, kind: kind, skip_concerns: skip_concerns, base_model: base_model) unless block
56
199
 
57
- paths(root, kind: kind, skip_concerns: skip_concerns) do |record|
200
+ read = lambda do |record|
58
201
  source = SafeFile.read(record.path) or next
59
- yield record.with(source: source)
202
+ block.call(record.with(source: source))
60
203
  end
204
+ return paths(root, kind: kind, skip_concerns: skip_concerns, &read) unless kind == :models
205
+
206
+ model_paths(root, base_model: base_model) { |record| read.call(record) unless skip_concerns && record.path_name.start_with?("Concerns::") }
61
207
  end
62
208
 
63
209
  # The eager form: reads and parses every file for its declared name.
64
210
  # A caller that names only some files resolves DeclaredConstant itself.
65
- def classes(root, kind:)
66
- each(root, kind: kind).filter_map do |record|
67
- next unless DeclaredConstant.declares_class?(record.source)
211
+ def classes(root, kind:, base_model: nil)
212
+ each(root, kind: kind, base_model: base_model).filter_map do |record|
213
+ next unless DeclaredConstant.declares_class?(record.source, path_name: record.path_name)
68
214
 
69
215
  [ DeclaredConstant.resolve(record.source, record.path_name), record ]
70
216
  end
@@ -74,16 +220,18 @@ module RailsAiContext
74
220
  # to a real path the root does not contain; the unresolved path is
75
221
  # still the one the app spells.
76
222
  def relative_file(path, real, root, real_root)
77
- real_prefix = real_root + File::SEPARATOR
223
+ real_prefix = SafePath.dir_prefix(real_root)
78
224
  return real.delete_prefix(real_prefix) if real.start_with?(real_prefix)
79
225
 
80
- path.delete_prefix(root + File::SEPARATOR)
226
+ path.delete_prefix(SafePath.dir_prefix(root))
81
227
  end
82
228
 
83
- # Whether either spelling of a path lands inside either spelling of the
84
- # root. Only one of the two holds for a symlinked pack.
229
+ # Either spelling may land inside the root (only one does for a symlinked
230
+ # pack), but a spelled ".." can climb out through a symlink, so it needs the real one.
85
231
  def under_root?(path, real, root, real_root)
86
- path.start_with?(root + File::SEPARATOR) || real.start_with?(real_root + File::SEPARATOR)
232
+ return true if SafePath.contained?(real, real_root)
233
+
234
+ !path.split(File::SEPARATOR).include?("..") && File.expand_path(path).start_with?(SafePath.dir_prefix(root))
87
235
  end
88
236
  end
89
237
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Rails 8 leaves a virtual table and its shadow tables out of connection.tables;
6
+ # Rails 7.x lists them as plain tables and has no virtual_tables, so both are read here.
7
+ module SqliteVirtualTables
8
+ module_function
9
+
10
+ # Each virtual table's [module, arguments], read from sqlite_master as Rails 8 does.
11
+ def of(connection)
12
+ connection.select_rows("SELECT name, sql FROM sqlite_master WHERE sql LIKE 'CREATE VIRTUAL %'").to_h do |name, sql|
13
+ [ name, sql.match(/USING\s+(\w+)\s*(?:\((.*)\))?/im).to_a.drop(1) ]
14
+ end
15
+ end
16
+
17
+ # ponytail: pragma_table_list needs SQLite 3.37+; an older SQLite under Rails 7.x still lists shadow tables.
18
+ def hidden(connection)
19
+ virtual = of(connection).keys
20
+ return virtual if virtual.empty?
21
+
22
+ virtual + connection.select_values("SELECT name FROM pragma_table_list WHERE type = 'shadow'")
23
+ rescue ActiveRecord::StatementInvalid
24
+ virtual
25
+ end
26
+ end
27
+ end
28
+ end
@@ -144,7 +144,7 @@ module RailsAiContext
144
144
  # the rails-new home an app's own loader can strip a segment the path keeps.
145
145
  def identifier_for(path, js_root)
146
146
  parts = path.sub("#{js_root}/", "").split("/")
147
- index = parts.rindex("controllers")
147
+ index = parts.index("controllers")
148
148
  stem = (index ? parts[(index + 1)..] : parts).join("/")
149
149
  .sub(/(?:_controller|\.controller)\.\w+\z/, "")
150
150
  [ stem.gsub("/", "--").tr("_", "-"), !default_home?(path, js_root) ]
@@ -159,7 +159,7 @@ module RailsAiContext
159
159
  # controller left without a free name is qualified by its code root and marked a guess.
160
160
  def resolve_names(paths, referenced, registered = {}, root: nil, loader_dirs: [])
161
161
  derived = paths.map { |path, js_root| [ path, js_root, identifier_for(path, js_root) ] }
162
- .sort_by { |path, js_root, _| [ js_root.count("/"), path ] }
162
+ .sort_by { |path, js_root, (_name, guess)| [ code_root_of(js_root).count("/"), guess ? 1 : 0, path ] }
163
163
  claimed = derived.map { |_path, _js_root, (name, _guess)| name }.to_set
164
164
  taken = Set.new
165
165
  resolved = {}
@@ -238,7 +238,7 @@ module RailsAiContext
238
238
 
239
239
  def controllers_home(path, js_root)
240
240
  parts = path.delete_prefix("#{js_root}/").split("/")
241
- index = parts.rindex("controllers") or return nil
241
+ index = parts.index("controllers") or return nil
242
242
  File.join(js_root, *parts.first(index + 1))
243
243
  end
244
244
 
@@ -255,11 +255,20 @@ module RailsAiContext
255
255
  end
256
256
  end
257
257
 
258
+ def js_dir_of(js_root)
259
+ JS_ROOTS.find { |dir| js_root == dir || js_root.end_with?("/#{dir}") }
260
+ end
261
+
262
+ # The code root a JS root belongs to: `frontend` and `app/javascript` share one.
263
+ def code_root_of(js_root)
264
+ js_root.delete_suffix(js_dir_of(js_root).to_s).chomp("/")
265
+ end
266
+
258
267
  # Names for a controller whose derived one is taken: prefixed by more and more
259
268
  # of its code root (`chat--foo`, `plugins--chat--foo`), then the whole path.
260
269
  def qualified_names(name, js_root, root)
261
270
  relative = root ? js_root.delete_prefix("#{root}/") : js_root
262
- js_dir = JS_ROOTS.find { |dir| relative == dir || relative.end_with?("/#{dir}") }
271
+ js_dir = js_dir_of(relative)
263
272
  segments = (js_dir ? relative.delete_suffix(js_dir).chomp("/") : relative).split("/").reject(&:empty?)
264
273
  segments = [ js_dir.tr("/", "-") ] if segments.empty? && js_dir
265
274
  (1..segments.size).map { |n| "#{segments.last(n).join('--')}--#{name}" } +
@@ -300,7 +309,9 @@ module RailsAiContext
300
309
  # Every template or Ruby file an identifier can be written in: the one
301
310
  # file set that naming and the "used in" lookup both read.
302
311
  def template_files(root)
303
- TEMPLATE_KINDS.flat_map { |kind| PathResolver.dirs_for(root, kind) }.uniq.flat_map { |dir|
312
+ dirs = TEMPLATE_KINDS.flat_map { |kind| PathResolver.dirs_for(root, kind) }.uniq
313
+ dirs += PathResolver.view_dirs(root).reject { |views| dirs.any? { |dir| views == dir || views.start_with?("#{dir}/") } }
314
+ dirs.flat_map { |dir|
304
315
  FileWalk.each_file(dir, skip: SKIP_DIRS).select { |path| path.match?(TEMPLATE_FILE) }.sort
305
316
  }.uniq
306
317
  end