rails-ai-context 5.32.2 → 5.34.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
@@ -9,8 +9,12 @@ module RailsAiContext
9
9
  # Generated files:
10
10
  # app/models/AGENTS.md - model listing, loaded when editing models
11
11
  # app/controllers/AGENTS.md - controller listing, loaded when editing controllers
12
+ #
13
+ # Teams write these by hand too, so one the gem did not generate keeps
14
+ # its text and gets the gem's listing as a marked block.
12
15
  class OpencodeRulesSerializer < Base
13
16
  include StackOverviewHelper
17
+ include ToolGuideHelper
14
18
 
15
19
  RULE_FILES = {
16
20
  "app/models/AGENTS.md" => { renderer: :render_models_reference, reason: "no models" },
@@ -29,15 +33,23 @@ module RailsAiContext
29
33
  if Dir.exist?(File.dirname(filepath))
30
34
  RuleFile.new(filepath, send(rule[:renderer]), rule[:reason])
31
35
  else
32
- RuleFile.new(filepath, nil, "#{File.dirname(relative)} not present")
36
+ RuleFile.new(filepath, nil, missing_directory_reason(File.dirname(relative), output_dir))
33
37
  end
34
38
  end
35
39
 
36
- write_rule_files(entries)
40
+ write_rule_files(entries, shared: true)
37
41
  end
38
42
 
39
43
  private
40
44
 
45
+ # OpenCode reads these beside the code, so they go only into a
46
+ # directory that exists. Under an output_dir that is not the app, the
47
+ # app's own directory is there; it is the output_dir that lacks one.
48
+ def missing_directory_reason(dir, output_dir)
49
+ elsewhere = File.expand_path(output_dir) != File.expand_path(project_root)
50
+ elsewhere && Dir.exist?(File.join(project_root, dir)) ? "no #{dir} under output_dir" : "#{dir} not present"
51
+ end
52
+
41
53
  def render_models_reference
42
54
  models = Payload.models(context)
43
55
  return nil unless models.any?
@@ -46,7 +58,7 @@ module RailsAiContext
46
58
  "# ActiveRecord Models (#{models.size})",
47
59
  "",
48
60
  "> #{Install::Cleanup::GENERATED_NOTE}",
49
- "> Check here first for scopes, constants, associations. Read model files for business logic/methods.",
61
+ "> #{MODELS_READING}",
50
62
  ""
51
63
  ]
52
64
  if (notice = SectionFacts.static_notice(context))
@@ -62,8 +74,9 @@ module RailsAiContext
62
74
 
63
75
  assocs = SectionFacts.associations_list(data).join(", ")
64
76
  vals = (data[:validations] || []).size
77
+ note = SectionFacts.table_note(Payload.section(context, :schema), data)
65
78
  line = "- **#{name}**"
66
- line += " (table: #{data[:table_name]})" if data[:table_name]
79
+ line += note ? " (table #{note})" : (" (table: #{data[:table_name]})" if data[:table_name]).to_s
67
80
  line += " - #{assocs}" unless assocs.empty?
68
81
  line += " [#{vals}v]" if vals > 0
69
82
  lines << line
@@ -72,14 +85,23 @@ module RailsAiContext
72
85
  end
73
86
 
74
87
  lines << "- _...#{models.size - 30} more_" if models.size > 30
75
- lines << ""
76
- lines << "Use `rails_get_model_details(model:\"Name\")` for associations, validations, scopes, enums."
77
- lines << "Use `rails_get_view(controller:\"name\")` for view templates."
78
- lines << "Use `rails_get_test_info(model:\"Name\")` for existing model tests."
88
+ lines.concat(tool_pointers(
89
+ [ "rails_get_model_details", 'model:"Name"', "model=Name", "for associations, validations, scopes, enums." ],
90
+ [ "rails_get_view", 'controller:"name"', "controller=name", "for view templates." ],
91
+ [ "rails_get_test_info", 'model:"Name"', "model=Name", "for existing model tests." ]
92
+ ))
79
93
 
80
94
  lines.join("\n")
81
95
  end
82
96
 
97
+ # "Use <tool> for ..." lines under a blank one, for the tools the server serves.
98
+ def tool_pointers(*pointers)
99
+ served = pointers.select { |name, *| served?(name) }
100
+ return [] if served.empty?
101
+
102
+ [ "", *served.map { |name, mcp_params, cli_params, purpose| "Use #{tool_ref(name, mcp_params, cli_params)} #{purpose}" } ]
103
+ end
104
+
83
105
  def render_controllers_reference
84
106
  app_controllers = Payload.app_controllers(context)
85
107
  return nil if app_controllers.empty?
@@ -88,7 +110,7 @@ module RailsAiContext
88
110
  "# Controllers (#{app_controllers.size})",
89
111
  "",
90
112
  "> #{Install::Cleanup::GENERATED_NOTE}",
91
- "> Read controller files directly when editing. Use MCP tools for reference only.",
113
+ "> Read controller files directly when editing. Use #{tools_noun} for reference only.",
92
114
  ""
93
115
  ]
94
116
  if (notice = SectionFacts.static_notice(context))
@@ -109,12 +131,13 @@ module RailsAiContext
109
131
  jobs = job_names
110
132
  lines << "**Jobs:** #{jobs.join(', ')}" if jobs.any?
111
133
 
112
- lines << ""
113
- lines << "Use `rails_get_controllers(controller:\"Name\", action:\"index\")` for one action's source code."
114
- lines << "Use `rails_get_edit_context(file:\"path\", near:\"keyword\")` for surgical edit context."
115
- lines << "Use `rails_get_view(controller:\"name\")` for view templates."
116
- lines << "Use `rails_get_test_info(controller:\"Name\")` for existing controller tests."
117
- lines << "Use `rails_validate(files:[...])` to check syntax after editing - do NOT re-read files to verify."
134
+ lines.concat(tool_pointers(
135
+ [ "rails_get_controllers", 'controller:"Name", action:"index"', "controller=Name action=index", "for one action's source code." ],
136
+ [ "rails_get_edit_context", 'file:"path", near:"keyword"', "file=path near=keyword", "for surgical edit context." ],
137
+ [ "rails_get_view", 'controller:"name"', "controller=name", "for view templates." ],
138
+ [ "rails_get_test_info", 'controller:"Name"', "controller=Name", "for existing controller tests." ],
139
+ [ "rails_validate", "files:[...]", "files=...", "to check syntax after editing - do NOT re-read files to verify." ]
140
+ ))
118
141
 
119
142
  lines.join("\n")
120
143
  end
@@ -33,11 +33,45 @@ module RailsAiContext
33
33
  models.any? ? "- Models: #{models.size}" : nil
34
34
  end
35
35
 
36
+ # The primary first, then each other database by its database.yml name:
37
+ # a count that leaves one out undercounts the app.
36
38
  def database_line(ctx)
37
39
  schema = Payload.section(ctx, :schema)
38
40
  return nil unless schema
39
41
 
40
- "- Database: #{SchemaAdapter.label(ctx)} - #{CountPhrase.call(schema[:total_tables].to_i, "table")}"
42
+ parts = [ "#{SchemaAdapter.label(ctx)} - #{CountPhrase.call(schema[:total_tables].to_i, "table")}" ]
43
+ Payload.secondary_databases(schema).each do |name, db|
44
+ tables = CountPhrase.call(db[:total_tables].to_i, "table")
45
+ adapter = SchemaAdapter.secondary_label(ctx, name, db)
46
+ parts << (framework_database_text(name, db) || "#{name}: #{adapter ? "#{adapter} - #{tables}" : tables}")
47
+ end
48
+ "- Database: #{parts.join('; ')}"
49
+ end
50
+
51
+ # A database a Rails framework keeps to itself, as every file names it:
52
+ # "queue: Solid Queue (13 tables)". Its tables are the framework's, so
53
+ # no file lists them. nil for a database of the app's own.
54
+ def framework_database_text(name, db)
55
+ framework = Payload.database_framework(db) or return nil
56
+
57
+ "#{name}: #{framework} (#{CountPhrase.call(db[:total_tables].to_i, "table")})"
58
+ end
59
+
60
+ # The same databases, the ones with migrations of their own.
61
+ def migrations_line(ctx)
62
+ migrations = Payload.section(ctx, :migrations)
63
+ return nil unless migrations
64
+
65
+ pending = Payload.pending_migrations(ctx) if migrations.key?(:pending)
66
+ parts = [ migration_counts(migrations[:total], pending) ]
67
+ secondary = migrations[:secondary_databases].is_a?(Hash) ? migrations[:secondary_databases] : {}
68
+ secondary.each { |name, db| parts << "#{name}: #{migration_counts(db[:total], db[:pending])}" }
69
+ "- Migrations: #{parts.join('; ')}"
70
+ end
71
+
72
+ # No pending count when nothing says what has been applied: zero would read as up to date.
73
+ def migration_counts(total, pending)
74
+ pending ? "#{total.to_i} total, #{pending.size} pending" : "#{total.to_i} total"
41
75
  end
42
76
 
43
77
  def auth_line(ctx)
@@ -78,6 +112,15 @@ module RailsAiContext
78
112
  buckets
79
113
  end
80
114
 
115
+ # The table a model reads when it is not plainly named: missing from a live listing, or set by
116
+ # a parent the static tier did not read. Nil otherwise.
117
+ def table_note(schema, model_data)
118
+ return "`#{model_data[:table_name]}` not found" if Payload.missing_table?(schema, model_data)
119
+ return nil if model_data[:table_name] || !model_data[:table_set_by]
120
+
121
+ "set by `#{model_data[:table_set_by]}`, which the static tier did not read"
122
+ end
123
+
81
124
  def associations_list(model_data)
82
125
  (model_data[:associations] || [])
83
126
  .select { |a| a.is_a?(Hash) }
@@ -6,7 +6,8 @@ module RailsAiContext
6
6
  # markers so user-added content OUTSIDE the markers survives regeneration.
7
7
  #
8
8
  # Used by ContextFileSerializer (for CLAUDE.md, AGENTS.md, .github/copilot-
9
- # instructions.md) and CursorRulesSerializer (for .cursorrules). Same
9
+ # instructions.md), CursorRulesSerializer (for .cursorrules) and, for a
10
+ # per-directory AGENTS.md the user wrote, OpencodeRulesSerializer. Same
10
11
  # marker contract everywhere - a user can hand-write content above or below
11
12
  # the gem-managed block and it'll persist across `rails ai:context` runs.
12
13
  module SectionMarkerWriter
@@ -20,29 +21,78 @@ module RailsAiContext
20
21
  # nothing new still put a diff in a repo that commits its context files.
21
22
  GENERATED_LINE = /^> Generated: .*\n/
22
23
 
24
+ BLOCK = /#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}(?:\r?\n)?/m
25
+
26
+ # Why a run left a file alone: with a marker lost, or a second block
27
+ # pasted in, nothing says where the gem's block ends.
28
+ UNPAIRED = "unpaired rails-ai-context marker, left as it is"
29
+
30
+ # Inside a file somebody else wrote, the note claims the block rather
31
+ # than the whole file, as Install::Cleanup::GENERATED_NOTE does.
32
+ BLOCK_NOTE = "This block is generated by rails-ai-context and rewritten on every run. " \
33
+ "Edit outside its BEGIN/END markers."
34
+
23
35
  module_function
24
36
 
37
+ # A file under a name users write by hand too (OpenCode's
38
+ # app/models/AGENTS.md), rendered whole with the generated note. One
39
+ # that is missing, or that this gem generated whole - the note and no
40
+ # marker - is written whole. Any other is somebody's own, and gets the
41
+ # content as a block the way a root file does, so their text stays and
42
+ # Install::Cleanup takes out only the block.
43
+ def write_whole_or_block(filepath, content)
44
+ existing = File.exist?(filepath) ? RailsAiContext::SafeFile.read_text(filepath) : nil
45
+ return write_whole(filepath, content) if existing.nil? || generated_whole?(existing)
46
+
47
+ write_with_markers(filepath, content.sub(Install::Cleanup::GENERATED_LINE, "> #{BLOCK_NOTE}"))
48
+ end
49
+
50
+ def generated_whole?(text)
51
+ markers(text) == :none && text.match?(Install::Cleanup::GENERATED_LINE)
52
+ end
53
+
54
+ # A file the gem owns whole, rewritten only when it says something else.
55
+ # It ends with a newline, as a text file does: without one, editors and
56
+ # end-of-file fixers rewrote every rule file and git flagged each.
57
+ def write_whole(filepath, content)
58
+ content = "#{content}\n" unless content.end_with?("\n")
59
+ if File.exist?(filepath)
60
+ existing = RailsAiContext::SafeFile.read_text(filepath)
61
+ content = in_line_endings_of(existing, content)
62
+ return :skipped if existing == content
63
+ end
64
+
65
+ atomic_write(filepath, content)
66
+ :written
67
+ end
68
+
25
69
  # Write `content` to `filepath` wrapped in markers. If the file already
26
70
  # exists with markers, replace only the marker block (preserving user
27
71
  # content outside). If the file exists WITHOUT markers, prepend the
28
72
  # marker block so AI tools read our context first while keeping the
29
- # user's prior content intact below.
73
+ # user's prior content intact below. A file whose markers do not pair
74
+ # up is not written: replacing a guess at the block could take the
75
+ # user's text, and prepending a new one duplicated the old for good.
30
76
  #
31
77
  # Returns :written if the file changed (created or block updated),
32
- # :skipped if the file already had the exact same marker block.
78
+ # :skipped if the file already had the exact same marker block,
79
+ # :unpaired if its markers do not pair up.
33
80
  def write_with_markers(filepath, content)
34
81
  marked_content = "#{BEGIN_MARKER}\n#{content}\n#{END_MARKER}\n"
35
82
 
36
83
  if File.exist?(filepath)
37
- existing = File.read(filepath)
38
-
39
- new_content = if existing.include?(BEGIN_MARKER) && existing.include?(END_MARKER)
40
- existing.sub(
41
- /#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m,
42
- marked_content
43
- )
44
- else
45
- "#{marked_content}\n#{existing}"
84
+ # Read as UTF-8 whatever the locale: a C locale tagged it US-ASCII,
85
+ # and the first non-ASCII character stopped the rewrite. A file that
86
+ # is no UTF-8 keeps its bytes, and the block joins them as bytes.
87
+ existing = RailsAiContext::SafeFile.read_text(filepath)
88
+ marked_content = in_line_endings_of(existing, marked_content)
89
+ marked_content = marked_content.b if existing.encoding == Encoding::BINARY
90
+
91
+ new_content = case markers(existing)
92
+ # A block, so a backslash in the content is not read as a backreference.
93
+ when :pair then existing.sub(BLOCK) { marked_content }
94
+ when :none then "#{marked_content}#{crlf?(existing) ? "\r\n" : "\n"}#{existing}"
95
+ else return :unpaired
46
96
  end
47
97
 
48
98
  return :skipped if same_but_for_timestamp?(existing, new_content)
@@ -55,6 +105,38 @@ module RailsAiContext
55
105
  end
56
106
  end
57
107
 
108
+ # :pair for one BEGIN marker before one END marker, :none for neither,
109
+ # :unpaired for anything else: a lone marker, two blocks, END first.
110
+ def markers(text)
111
+ begins = text.scan(BEGIN_MARKER).size
112
+ ends = text.scan(END_MARKER).size
113
+ return :none if begins.zero? && ends.zero?
114
+ return :pair if begins == 1 && ends == 1 && text.index(BEGIN_MARKER) < text.index(END_MARKER)
115
+
116
+ :unpaired
117
+ end
118
+
119
+ # Files a write's outcome under the bucket a context run reports it in.
120
+ def report(outcome, filepath, result)
121
+ case outcome
122
+ when :written then result[:written] << filepath
123
+ when :skipped then result[:skipped] << filepath
124
+ when :unpaired then result[:not_applicable][filepath] = UNPAIRED
125
+ end
126
+ result
127
+ end
128
+
129
+ # Text in the line endings of the file it goes into. A file checked out
130
+ # on Windows ends its lines in CRLF, and a block in LF beside them
131
+ # mixed the two and left a blank line under END on every run.
132
+ def in_line_endings_of(existing, text)
133
+ crlf?(existing) ? text.gsub(/\r?\n/, "\r\n") : text
134
+ end
135
+
136
+ def crlf?(text)
137
+ text.include?("\r\n")
138
+ end
139
+
58
140
  def same_but_for_timestamp?(existing, candidate)
59
141
  existing == candidate ||
60
142
  existing.gsub(GENERATED_LINE, "") == candidate.gsub(GENERATED_LINE, "")
@@ -13,6 +13,12 @@ module RailsAiContext
13
13
  RuleFile = Struct.new(:path, :content, :reason)
14
14
 
15
15
  IN_REPO_ENGINES_SHOWN = 5
16
+ DATABASES_SHOWN = 3
17
+
18
+ # The models listings' line on reading, in the tools guide's terms: the
19
+ # tools are the reference, and a file is read to edit it. "Read model
20
+ # files for business logic" sat beside "NEVER read ... model files".
21
+ MODELS_READING = "Check here first for scopes, constants, associations. Read a model file only when you edit it."
16
22
 
17
23
  # Returns an array of summary lines for full-preset introspectors.
18
24
  # Each line is only added if the introspector returned meaningful data.
@@ -78,7 +84,10 @@ module RailsAiContext
78
84
  db_list = raw_databases.is_a?(Hash) ? raw_databases.keys : Array(raw_databases)
79
85
  if db_list.size > 1
80
86
  db_names = db_list.map { |d| d.is_a?(Hash) ? d[:name] : d }
81
- lines << "- Databases: #{db_list.size} (#{db_names.first(3).join(', ')})"
87
+ # The names it leaves out are counted, as the mounted apps' are.
88
+ shown = db_names.first(DATABASES_SHOWN)
89
+ shown << "...#{db_names.size - DATABASES_SHOWN} more" if db_names.size > DATABASES_SHOWN
90
+ lines << "- Databases: #{db_list.size} (#{shown.join(', ')})"
82
91
  end
83
92
 
84
93
  components = Payload.section(ctx, :components)
@@ -116,7 +125,7 @@ module RailsAiContext
116
125
  lines << SectionFacts.models_line(context)
117
126
 
118
127
  if (routes = Payload.section(context, :routes))
119
- lines << "- Routes: #{routes[:total_routes]}#{RouteCoverage.suffix(routes)}"
128
+ lines << "- Routes: #{RouteCoverage.summary(routes)}"
120
129
  end
121
130
 
122
131
  if gems
@@ -214,28 +223,31 @@ module RailsAiContext
214
223
  # A nil render means the app has nothing to put in that file. It is
215
224
  # reported rather than dropped, so a deliberate omission never looks
216
225
  # like a failed generation.
226
+ #
227
+ # A rules directory's names are the gem's own. `shared:` is for names
228
+ # users write by hand too (OpenCode's app/models/AGENTS.md): a file
229
+ # there the gem did not generate whole keeps its text and gets the
230
+ # gem's block, as a root file does.
217
231
  # @param entries [Array<RuleFile>]
218
232
  # @return [Hash] { written: [paths], skipped: [paths], not_applicable: { path => reason } }
219
- def write_rule_files(entries)
220
- written = []
221
- skipped = []
222
- not_applicable = {}
233
+ def write_rule_files(entries, shared: false)
234
+ result = { written: [], skipped: [], not_applicable: {} }
223
235
 
224
236
  entries.each do |entry|
225
237
  if entry.content.nil?
226
- not_applicable[entry.path] = entry.reason || "nothing to document"
238
+ result[:not_applicable][entry.path] = entry.reason || "nothing to document"
227
239
  next
228
240
  end
229
241
 
230
- if File.exist?(entry.path) && File.read(entry.path) == entry.content
231
- skipped << entry.path
242
+ outcome = if shared
243
+ SectionMarkerWriter.write_whole_or_block(entry.path, entry.content)
232
244
  else
233
- SafeFile.atomic_write(entry.path, entry.content)
234
- written << entry.path
245
+ SectionMarkerWriter.write_whole(entry.path, entry.content)
235
246
  end
247
+ SectionMarkerWriter.report(outcome, entry.path, result)
236
248
  end
237
249
 
238
- { written: written, skipped: skipped, not_applicable: not_applicable }
250
+ result
239
251
  end
240
252
 
241
253
  def project_root