rails-ai-context 5.19.0 → 5.19.1

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/exe/rails-ai-context +6 -8
  4. data/lib/generators/rails_ai_context/install/install_generator.rb +6 -6
  5. data/lib/rails_ai_context/count_phrase.rb +24 -0
  6. data/lib/rails_ai_context/doctor.rb +21 -17
  7. data/lib/rails_ai_context/facts_formatter.rb +5 -3
  8. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +3 -2
  9. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +38 -21
  10. data/lib/rails_ai_context/introspectors/schema_introspector.rb +1 -1
  11. data/lib/rails_ai_context/introspectors/source_introspector.rb +1 -0
  12. data/lib/rails_ai_context/live_reload.rb +6 -4
  13. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +4 -4
  14. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +8 -6
  15. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
  16. data/lib/rails_ai_context/serializers/copilot_serializer.rb +3 -3
  17. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +3 -3
  18. data/lib/rails_ai_context/serializers/markdown_serializer.rb +4 -4
  19. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +12 -10
  20. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +5 -3
  21. data/lib/rails_ai_context/tasks/rails_ai_context.rake +2 -2
  22. data/lib/rails_ai_context/tools/analyze_feature.rb +5 -5
  23. data/lib/rails_ai_context/tools/base_tool.rb +2 -0
  24. data/lib/rails_ai_context/tools/get_active_support.rb +1 -1
  25. data/lib/rails_ai_context/tools/get_api.rb +3 -3
  26. data/lib/rails_ai_context/tools/get_callbacks.rb +2 -2
  27. data/lib/rails_ai_context/tools/get_component_catalog.rb +2 -2
  28. data/lib/rails_ai_context/tools/get_concern.rb +3 -3
  29. data/lib/rails_ai_context/tools/get_controllers.rb +1 -1
  30. data/lib/rails_ai_context/tools/get_conventions.rb +1 -1
  31. data/lib/rails_ai_context/tools/get_edit_context.rb +1 -1
  32. data/lib/rails_ai_context/tools/get_engines.rb +2 -2
  33. data/lib/rails_ai_context/tools/get_env_config.rb +1 -1
  34. data/lib/rails_ai_context/tools/get_frontend_stack.rb +6 -6
  35. data/lib/rails_ai_context/tools/get_helper_methods.rb +2 -2
  36. data/lib/rails_ai_context/tools/get_i18n.rb +10 -3
  37. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -3
  38. data/lib/rails_ai_context/tools/get_model_details.rb +2 -2
  39. data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -3
  40. data/lib/rails_ai_context/tools/get_routes.rb +5 -5
  41. data/lib/rails_ai_context/tools/get_schema.rb +6 -6
  42. data/lib/rails_ai_context/tools/get_service_pattern.rb +3 -3
  43. data/lib/rails_ai_context/tools/get_stimulus.rb +5 -5
  44. data/lib/rails_ai_context/tools/get_test_info.rb +8 -8
  45. data/lib/rails_ai_context/tools/get_view.rb +11 -11
  46. data/lib/rails_ai_context/tools/onboard.rb +5 -10
  47. data/lib/rails_ai_context/tools/query.rb +1 -1
  48. data/lib/rails_ai_context/tools/read_logs.rb +1 -1
  49. data/lib/rails_ai_context/tools/review_changes.rb +3 -3
  50. data/lib/rails_ai_context/tools/runtime_info.rb +3 -3
  51. data/lib/rails_ai_context/tools/search_code.rb +5 -5
  52. data/lib/rails_ai_context/tools/search_docs.rb +2 -2
  53. data/lib/rails_ai_context/tools/security_scan.rb +9 -4
  54. data/lib/rails_ai_context/tools/session_context.rb +2 -2
  55. data/lib/rails_ai_context/version.rb +1 -1
  56. data/server.json +1 -1
  57. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46645f9291dee2458c1a03529fcf888bfd4d11be1d742310f224ae439baed82f
4
- data.tar.gz: 6c1a79f00117f9617970e5c0f1380337fdc17578fe350515d1a8d301a0fef3fd
3
+ metadata.gz: 175ccbf5bac4703a8f9699708a02435a9b80546c09129deae79ffb5f182e14e6
4
+ data.tar.gz: 0adbdf439e6ac853606fc786d6f92041a70aec4e2379ebfe98132eb14352b219
5
5
  SHA512:
6
- metadata.gz: 6ffd81a61373550ad0df085b3c01dc647de386bd79773b148711d8f040969f8a7f71fffb4e059539deef7285fc1fa21480761bd9f04cbaabd7aaaebab0e84e9d
7
- data.tar.gz: c83fe3ae8dfa8bb663bba3b09dd6cd8b3b29939e2ae7ce6cf379cb9df0f739a2b21e86976a6741bee103240324f6e6118ba1fdf85b70d36927a702521e48a159
6
+ metadata.gz: 9bb08e8b5d06124360be6870c7f667f008a666d07bea55a33b89662049ea1ee00b3ab88b5c5060ee70c47b8d1b77c422cfd8434f3327094427e2ac321c8a9201
7
+ data.tar.gz: 160c8b647c1a6be4bec7290be0308a98318edd92c2525235410bd1fed94459ddd107538f5ced4c8fced29745a9de00aa8dfc37f0bdc9274afa358c0aa2f82e3f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.19.1] - 2026-08-10
9
+
10
+ ### Fixed
11
+
12
+ - **Static-tier routes no longer inherit a closed `namespace` block.**
13
+ `SourceIntrospector` registered its listeners for every Prism event except
14
+ `:on_call_node_leave`, so the two listeners that pop a scope stack never
15
+ received it. With `--no-boot`, every route declared after a
16
+ `namespace :admin do ... end` block was reported under that namespace, so an
17
+ app with `/comments` and `/health` served `/admin/comments` and
18
+ `/admin/health` tagged `[VERIFIED]`. The same cause put a gem declared after
19
+ a `group :development do ... end` block into that group in
20
+ `rails_get_gems`' group listing. Booted mode was never affected.
21
+ - **`rails_get_i18n` coverage is measured against the default locale's keys.**
22
+ It compared raw key counts, so a locale defining five keys against a
23
+ one-key default reported `500.0%`. Coverage is now the share of the default
24
+ locale's keys the other locale also defines, and each locale reports how
25
+ many keys are missing and how many it adds beyond the default.
26
+ - **`rails_get_autoload` reports each path once.** Rails lists a path once per
27
+ railtie that contributed it, so an app using `config.autoload_lib` showed
28
+ `lib` twice under a count of two.
29
+ - **Counts of one read as singular.** Tools, generated context files, `doctor`,
30
+ `facts`, the rake tasks and the standalone CLI wrote `1 keys`,
31
+ `1 associations`, `1 pending migration(s)` and the like. A locale file with
32
+ one key now reads `1 key`, a model with one association reads
33
+ `1 association`, and `facts` reads `1 index, 1 FK`. Counts render through
34
+ one shared helper, replacing the four spellings that had grown up across the
35
+ render code: raw interpolation, a `== 1 ?` ternary, an `#{"s" unless n == 1}`
36
+ suffix, and the `(s)` hedge. A handful of sites keep raw interpolation on
37
+ purpose: ratios (`1/1 files passed`), diff stats, and byte or row counts
38
+ whose branch only runs past a limit.
39
+ - **A locale file with a symbol root key counts toward coverage.** Both `en:`
40
+ and `:en:` load as valid YAML, but only the string form had its root
41
+ stripped, so a symbol-rooted locale compared its `es.`-prefixed paths
42
+ against nothing and scored 0%.
43
+
8
44
  ## [5.19.0] - 2026-08-09
9
45
 
10
46
  ### Added - 6 new tools surfacing previously unserved introspection (45 tools total)
data/exe/rails-ai-context CHANGED
@@ -222,7 +222,7 @@ class RailsAiContextCLI < Thor
222
222
 
223
223
  failed = result[:checks].count { |c| c.status == :fail }
224
224
  if options[:strict] && failed > 0
225
- $stderr.puts "#{failed} check#{'s' unless failed == 1} failed (--strict)"
225
+ $stderr.puts "#{RailsAiContext::CountPhrase.call(failed, "check")} failed (--strict)"
226
226
  exit 1
227
227
  end
228
228
  end
@@ -552,7 +552,7 @@ class RailsAiContextCLI < Thor
552
552
  $stderr.puts ""
553
553
  $stderr.puts "Commands:"
554
554
  $stderr.puts " rails-ai-context context # Regenerate context files"
555
- $stderr.puts " rails-ai-context tool NAME # Run any of the #{tool_count_str} tools"
555
+ $stderr.puts " rails-ai-context tool NAME # Run any of the #{RailsAiContext::CountPhrase.call(builtin_tool_count, "tool")}"
556
556
  if tool_mode == :mcp
557
557
  $stderr.puts " rails-ai-context serve # Start MCP server"
558
558
  end
@@ -571,12 +571,10 @@ class RailsAiContextCLI < Thor
571
571
  $stderr.puts ""
572
572
  end
573
573
 
574
- def tool_count_str
575
- if defined?(::RailsAiContext::Server)
576
- ::RailsAiContext::Server.builtin_tools.size.to_s
577
- else
578
- "45"
579
- end
574
+ def builtin_tool_count
575
+ return ::RailsAiContext::Server.builtin_tools.size if defined?(::RailsAiContext::Server)
576
+
577
+ 45
580
578
  end
581
579
 
582
580
  def print_context_result(result)
@@ -215,8 +215,8 @@ module RailsAiContext
215
215
  "Introspection" => <<~SECTION,
216
216
  # ── Introspection ─────────────────────────────────────────────────
217
217
  # Introspector preset:
218
- # :full - all #{RailsAiContext::Configuration::PRESETS[:full].size} introspectors (default)
219
- # :standard - #{RailsAiContext::Configuration::PRESETS[:standard].size} core introspectors (schema, models, routes, jobs, gems,
218
+ # :full - all #{CountPhrase.call(RailsAiContext::Configuration::PRESETS[:full].size, 'introspector')} (default)
219
+ # :standard - #{CountPhrase.call(RailsAiContext::Configuration::PRESETS[:standard].size, 'core introspector')} (schema, models, routes, jobs, gems,
220
220
  # conventions, controllers, tests, migrations, stimulus,
221
221
  # view_templates, config, components)
222
222
  # config.preset = :full
@@ -283,7 +283,7 @@ module RailsAiContext
283
283
  SECTION
284
284
  "Extensibility" => <<~SECTION,
285
285
  # ── Extensibility ─────────────────────────────────────────────────
286
- # Register additional MCP tool classes alongside the #{RailsAiContext::Server.builtin_tools.size} built-in tools
286
+ # Register additional MCP tool classes alongside the #{CountPhrase.call(RailsAiContext::Server.builtin_tools.size, 'built-in tool')}
287
287
  # config.custom_tools = ["MyApp::CustomTool"] # class name as string - resolved after boot
288
288
 
289
289
  # Exclude specific built-in tools by name
@@ -406,7 +406,7 @@ module RailsAiContext
406
406
  content, = ensure_initializer_guard(content)
407
407
 
408
408
  create_file path, content
409
- say "Created #{path} with all #{CONFIG_SECTIONS.size} config sections", :green
409
+ say "Created #{path} with all #{CountPhrase.call(CONFIG_SECTIONS.size, "config section")}", :green
410
410
  end
411
411
 
412
412
  def update_existing_initializer(full_path)
@@ -693,9 +693,9 @@ module RailsAiContext
693
693
  say "Commands:", :yellow
694
694
  say " rails ai:context # Regenerate context files"
695
695
  tool_count = RailsAiContext::Server.builtin_tools.size
696
- say " rails 'ai:tool[schema]' # Run any of the #{tool_count} tools from CLI"
696
+ say " rails 'ai:tool[schema]' # Run any of the #{CountPhrase.call(tool_count, "tool")} from CLI"
697
697
  if @tool_mode == :mcp
698
- say " rails ai:serve # Start MCP server (#{tool_count} live tools)"
698
+ say " rails ai:serve # Start MCP server (#{CountPhrase.call(tool_count, "live tool")})"
699
699
  end
700
700
  say " rails ai:facts # Print concise schema facts summary"
701
701
  say " rails 'ai:preset[arch]' # Run multi-tool presets (architecture, debugging, migration)"
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module RailsAiContext
6
+ # Renders a count with its noun. Raw interpolation reads wrong at one
7
+ # ("1 models"), and every tool and serializer that reports a count needs
8
+ # the same fix, so they share this rather than each spelling it out.
9
+ module CountPhrase
10
+ # Pass `plural` where the inflector's answer is wrong for the domain:
11
+ # database docs say indexes, not the inflector's indices.
12
+ def self.call(count, noun, plural: nil)
13
+ "#{count} #{count == 1 ? noun : plural || noun.pluralize}"
14
+ end
15
+
16
+ private
17
+
18
+ # Mixed in for the classes; rake tasks and generators, which have no class
19
+ # to mix into, call CountPhrase.call directly.
20
+ def count_phrase(count, noun, plural: nil)
21
+ CountPhrase.call(count, noun, plural: plural)
22
+ end
23
+ end
24
+ end
@@ -4,6 +4,8 @@ module RailsAiContext
4
4
  # Diagnostic checker that validates the environment and reports
5
5
  # AI readiness with pass/warn/fail checks and a readiness score.
6
6
  class Doctor
7
+ include CountPhrase
8
+
7
9
  Check = Data.define(:name, :status, :message, :fix)
8
10
 
9
11
  CHECKS = %i[
@@ -67,7 +69,7 @@ module RailsAiContext
67
69
  structure_path = File.join(app.root, "db/structure.sql")
68
70
  if File.exist?(schema_path)
69
71
  lines = File.readlines(schema_path).size
70
- Check.new(name: "Schema", status: :pass, message: "db/schema.rb found (#{lines} lines)", fix: nil)
72
+ Check.new(name: "Schema", status: :pass, message: "db/schema.rb found (#{count_phrase(lines, "line")})", fix: nil)
71
73
  elsif File.exist?(structure_path)
72
74
  size = (File.size(structure_path) / 1024.0).round(1)
73
75
  Check.new(name: "Schema", status: :pass, message: "db/structure.sql found (#{size}KB)", fix: nil)
@@ -86,7 +88,7 @@ module RailsAiContext
86
88
  Check.new(name: "Pending migrations", status: :pass, message: "No pending migrations", fix: nil)
87
89
  else
88
90
  Check.new(name: "Pending migrations", status: :fail,
89
- message: "#{pending.size} pending migration(s) - schema data will be stale",
91
+ message: "#{count_phrase(pending.size, "pending migration")} - schema data will be stale",
90
92
  fix: "Run `rails db:migrate`")
91
93
  end
92
94
  end
@@ -95,7 +97,7 @@ module RailsAiContext
95
97
  models_dir = File.join(app.root, "app/models")
96
98
  if Dir.exist?(models_dir) && Dir.glob(File.join(models_dir, "**/*.rb")).any?
97
99
  count = Dir.glob(File.join(models_dir, "**/*.rb")).size
98
- Check.new(name: "Models", status: :pass, message: "#{count} model files found", fix: nil)
100
+ Check.new(name: "Models", status: :pass, message: "#{count_phrase(count, "model file")} found", fix: nil)
99
101
  else
100
102
  Check.new(name: "Models", status: :warn, message: "No model files in app/models/", fix: "Generate models with `rails generate model`")
101
103
  end
@@ -123,7 +125,7 @@ module RailsAiContext
123
125
  dir = File.join(app.root, "app/controllers")
124
126
  if Dir.exist?(dir) && Dir.glob(File.join(dir, "**/*.rb")).any?
125
127
  count = Dir.glob(File.join(dir, "**/*.rb")).size
126
- Check.new(name: "Controllers", status: :pass, message: "#{count} controller files found", fix: nil)
128
+ Check.new(name: "Controllers", status: :pass, message: "#{count_phrase(count, "controller file")} found", fix: nil)
127
129
  else
128
130
  Check.new(name: "Controllers", status: :warn, message: "No controller files", fix: nil)
129
131
  end
@@ -133,7 +135,7 @@ module RailsAiContext
133
135
  dir = File.join(app.root, "app/views")
134
136
  if Dir.exist?(dir)
135
137
  count = Dir.glob(File.join(dir, "**/*")).reject { |f| File.directory?(f) }.size
136
- Check.new(name: "Views", status: :pass, message: "#{count} view files found", fix: nil)
138
+ Check.new(name: "Views", status: :pass, message: "#{count_phrase(count, "view file")} found", fix: nil)
137
139
  else
138
140
  Check.new(name: "Views", status: :warn, message: "No view files", fix: nil)
139
141
  end
@@ -153,7 +155,7 @@ module RailsAiContext
153
155
  migrate_dir = File.join(app.root, "db/migrate")
154
156
  if Dir.exist?(migrate_dir) && Dir.glob(File.join(migrate_dir, "*.rb")).any?
155
157
  count = Dir.glob(File.join(migrate_dir, "*.rb")).size
156
- Check.new(name: "Migrations", status: :pass, message: "#{count} migration #{count == 1 ? 'file' : 'files'}", fix: nil)
158
+ Check.new(name: "Migrations", status: :pass, message: count_phrase(count, "migration file"), fix: nil)
157
159
  else
158
160
  Check.new(name: "Migrations", status: :warn, message: "No migrations", fix: nil)
159
161
  end
@@ -289,13 +291,13 @@ module RailsAiContext
289
291
 
290
292
  if failures.empty?
291
293
  Check.new(name: "MCP configs", status: :pass,
292
- message: "#{checks.size} of #{checks.size} MCP config(s) valid",
294
+ message: "#{checks.size} of #{count_phrase(checks.size, "MCP config")} valid",
293
295
  fix: nil)
294
296
  else
295
297
  labels = failures.map { |c| c.name }
296
298
  worst_status = failures.any? { |c| c.status == :fail } ? :fail : :warn
297
299
  Check.new(name: "MCP configs", status: worst_status,
298
- message: "#{failures.size} of #{checks.size} MCP config(s) need attention: #{labels.join(', ')}",
300
+ message: "#{failures.size} of #{count_phrase(checks.size, "MCP config")} #{failures.size == 1 ? "needs" : "need"} attention: #{labels.join(', ')}",
299
301
  fix: "Run `rails generate rails_ai_context:install` to fix")
300
302
  end
301
303
  end
@@ -401,12 +403,12 @@ module RailsAiContext
401
403
 
402
404
  if errors.empty?
403
405
  Check.new(name: "Introspector health", status: :pass,
404
- message: "All #{config.introspectors.size} introspectors return data " \
405
- "(these feed the #{Server.builtin_tools.size} MCP tools)",
406
+ message: "All #{count_phrase(config.introspectors.size, "introspector")} return data " \
407
+ "(these feed the #{count_phrase(Server.builtin_tools.size, "MCP tool")})",
406
408
  fix: nil)
407
409
  else
408
410
  Check.new(name: "Introspector health", status: :warn,
409
- message: "#{errors.size} introspector(s) returned errors: #{errors.join(', ')}",
411
+ message: "#{count_phrase(errors.size, "introspector")} returned errors: #{errors.join(', ')}",
410
412
  fix: "Check if the app has the required features (e.g., stimulus needs app/javascript/controllers/)")
411
413
  end
412
414
  rescue => e
@@ -420,7 +422,8 @@ module RailsAiContext
420
422
 
421
423
  stimulus_dir = File.join(app.root, "app/javascript/controllers")
422
424
  if Dir.exist?(stimulus_dir) && Dir.glob(File.join(stimulus_dir, "**/*_controller.{js,ts}")).any? && !config.introspectors.include?(:stimulus)
423
- suggestions << "stimulus (#{Dir.glob(File.join(stimulus_dir, '**/*_controller.{js,ts}')).size} controllers found)"
425
+ stimulus_count = Dir.glob(File.join(stimulus_dir, "**/*_controller.{js,ts}")).size
426
+ suggestions << "stimulus (#{count_phrase(stimulus_count, "controller")} found)"
424
427
  end
425
428
 
426
429
  views_dir = File.join(app.root, "app/views")
@@ -429,8 +432,9 @@ module RailsAiContext
429
432
  end
430
433
 
431
434
  i18n_dir = File.join(app.root, "config/locales")
432
- if Dir.exist?(i18n_dir) && Dir.glob(File.join(i18n_dir, "**/*.{yml,yaml}")).size > 1 && !config.introspectors.include?(:i18n)
433
- suggestions << "i18n (#{Dir.glob(File.join(i18n_dir, '**/*.{yml,yaml}')).size} locale files)"
435
+ locale_file_count = Dir.exist?(i18n_dir) ? Dir.glob(File.join(i18n_dir, "**/*.{yml,yaml}")).size : 0
436
+ if locale_file_count > 1 && !config.introspectors.include?(:i18n)
437
+ suggestions << "i18n (#{count_phrase(locale_file_count, "locale file")})"
434
438
  end
435
439
 
436
440
  graphql_dir = File.join(app.root, "app/graphql")
@@ -440,7 +444,7 @@ module RailsAiContext
440
444
 
441
445
  if suggestions.empty?
442
446
  Check.new(name: "Preset coverage", status: :pass,
443
- message: "#{config.introspectors.size} introspectors cover detected features",
447
+ message: "#{count_phrase(config.introspectors.size, "introspector")} cover detected features",
444
448
  fix: nil)
445
449
  else
446
450
  Check.new(name: "Preset coverage", status: :warn,
@@ -647,11 +651,11 @@ module RailsAiContext
647
651
 
648
652
  if pct >= 80
649
653
  Check.new(name: "View aggregation size", status: :warn,
650
- message: "#{count} view files totaling #{(total_size / 1_000_000.0).round(1)}MB (#{pct}% of #{(limit / 1_000_000.0).round}MB limit for UI pattern extraction)",
654
+ message: "#{count_phrase(count, "view file")} totaling #{(total_size / 1_000_000.0).round(1)}MB (#{pct}% of #{(limit / 1_000_000.0).round}MB limit for UI pattern extraction)",
651
655
  fix: "Increase `config.max_view_total_size` or `config.max_view_file_size`")
652
656
  else
653
657
  Check.new(name: "View aggregation size", status: :pass,
654
- message: "#{count} view files (#{(total_size / 1024.0).round}KB total, within limits)",
658
+ message: "#{count_phrase(count, "view file")} (#{(total_size / 1024.0).round}KB total, within limits)",
655
659
  fix: nil)
656
660
  end
657
661
  end
@@ -5,6 +5,8 @@ module RailsAiContext
5
5
  # the standalone CLI (rails-ai-context facts) so both outputs stay in sync.
6
6
  class FactsFormatter
7
7
  class << self
8
+ include CountPhrase
9
+
8
10
  def render(context, inspect_hint: "rails ai:inspect")
9
11
  lines = []
10
12
  lines << "# #{app_name(context)} - Schema Facts"
@@ -41,9 +43,9 @@ module RailsAiContext
41
43
  cols = meta[:columns]&.size || 0
42
44
  indexes = meta[:indexes]&.size || 0
43
45
  fks = meta[:foreign_keys]&.size || 0
44
- lines << "- #{name} (#{cols} #{cols == 1 ? 'col' : 'cols'}, " \
45
- "#{indexes} #{indexes == 1 ? 'index' : 'indexes'}, " \
46
- "#{fks} #{fks == 1 ? 'FK' : 'FKs'})"
46
+ lines << "- #{name} (#{count_phrase(cols, "col")}, " \
47
+ "#{count_phrase(indexes, "index", plural: "indexes")}, " \
48
+ "#{count_phrase(fks, "FK", plural: "FKs")})"
47
49
  end
48
50
  lines << ""
49
51
  lines
@@ -133,11 +133,12 @@ module RailsAiContext
133
133
  "#{hit[:method]}: #{values.first}"
134
134
  end
135
135
 
136
+ # Rails lists a path once per railtie that contributed it.
136
137
  def relativize(paths)
137
- Array(paths).map do |p|
138
+ Array(paths).map { |p|
138
139
  s = p.to_s
139
140
  s.start_with?(root) ? s.sub("#{root}/", "") : s
140
- end
141
+ }.uniq
141
142
  end
142
143
  end
143
144
  end
@@ -61,9 +61,8 @@ module RailsAiContext
61
61
  Dir.glob(File.join(dir, "**/*.{yml,yaml,rb}")).size
62
62
  end
63
63
 
64
- def count_keys(hash, depth: 0)
65
- return 0 unless hash.is_a?(Hash)
66
- hash.sum { |_, v| v.is_a?(Hash) ? count_keys(v, depth: depth + 1) : 1 }
64
+ def count_keys(hash)
65
+ nested_key_paths(hash).size
67
66
  end
68
67
 
69
68
  def detect_fallback_config
@@ -79,12 +78,21 @@ module RailsAiContext
79
78
  locales = I18n.available_locales
80
79
  return {} if locales.size < 2
81
80
 
82
- # Compare key counts between default and other locales
81
+ # Coverage is the share of the default locale's keys that the other
82
+ # locale also defines. Comparing raw counts instead reports over 100%
83
+ # for a locale that translates few default keys but adds many of its
84
+ # own - the one number a translator must not be told is fine.
83
85
  coverage = {}
84
- default_count = count_keys_for_locale(I18n.default_locale)
86
+ default_keys = key_paths_for_locale(I18n.default_locale)
85
87
  locales.reject { |l| l == I18n.default_locale }.each do |locale|
86
- locale_count = count_keys_for_locale(locale)
87
- coverage[locale.to_s] = { keys: locale_count, coverage_pct: default_count > 0 ? ((locale_count.to_f / default_count) * 100).round(1) : 0 }
88
+ locale_keys = key_paths_for_locale(locale)
89
+ translated = (default_keys & locale_keys).size
90
+ coverage[locale.to_s] = {
91
+ keys: locale_keys.size,
92
+ missing: (default_keys - locale_keys).size,
93
+ extra: (locale_keys - default_keys).size,
94
+ coverage_pct: default_keys.any? ? ((translated.to_f / default_keys.size) * 100).round(1) : 0
95
+ }
88
96
  end
89
97
  coverage
90
98
  rescue => e
@@ -92,20 +100,26 @@ module RailsAiContext
92
100
  {}
93
101
  end
94
102
 
95
- def count_keys_for_locale(locale)
96
- paths = find_locale_paths(locale)
97
- return 0 if paths.empty?
98
- paths.sum do |path|
103
+ # Dotted key paths a locale defines, with the locale root stripped so
104
+ # `en.posts.title` and `es.posts.title` compare as the same key.
105
+ def key_paths_for_locale(locale)
106
+ loc = locale.to_s
107
+ find_locale_paths(locale).flat_map do |path|
99
108
  content = RailsAiContext::SafeFile.read(path)
100
- next 0 unless content
109
+ next [] unless content
101
110
  data = YAML.safe_load(content, permitted_classes: [ Symbol ])
102
- count_nested_keys(data)
111
+ next [] unless data.is_a?(Hash)
112
+
113
+ # A locale root may be written `en:` or `:en:` - both load, and both
114
+ # have to be stripped or this locale's paths compare against nothing.
115
+ root = data.key?(loc) ? data[loc] : data.fetch(locale.to_sym, data)
116
+ nested_key_paths(root)
103
117
  rescue StandardError
104
- 0
105
- end
118
+ []
119
+ end.uniq
106
120
  rescue => e
107
- $stderr.puts "[rails-ai-context] count_keys_for_locale failed: #{e.message}" if ENV["DEBUG"]
108
- 0
121
+ $stderr.puts "[rails-ai-context] key_paths_for_locale failed: #{e.message}" if ENV["DEBUG"]
122
+ []
109
123
  end
110
124
 
111
125
  # Finds all YAML files contributing translations for the given locale:
@@ -124,10 +138,13 @@ module RailsAiContext
124
138
  end
125
139
  end
126
140
 
127
- def count_nested_keys(hash, count = 0)
128
- return count unless hash.is_a?(Hash)
129
- hash.each_value { |v| count = v.is_a?(Hash) ? count_nested_keys(v, count) : count + 1 }
130
- count
141
+ def nested_key_paths(hash, prefix = nil, paths = [])
142
+ return paths unless hash.is_a?(Hash)
143
+ hash.each do |key, value|
144
+ path = prefix ? "#{prefix}.#{key}" : key.to_s
145
+ value.is_a?(Hash) ? nested_key_paths(value, path, paths) : paths << path
146
+ end
147
+ paths
131
148
  end
132
149
  end
133
150
  end
@@ -645,7 +645,7 @@ module RailsAiContext
645
645
  adapter: "static_parse",
646
646
  tables: tables,
647
647
  total_tables: tables.size,
648
- note: "Reconstructed from #{migration_files.size} migration files (no DB connection, no schema.rb)"
648
+ note: "Reconstructed from #{CountPhrase.call(migration_files.size, "migration file")} (no DB connection, no schema.rb)"
649
649
  }
650
650
  end
651
651
 
@@ -66,6 +66,7 @@ module RailsAiContext
66
66
  def self.register_listener(dispatcher, listener)
67
67
  events = []
68
68
  events << :on_call_node_enter if listener.respond_to?(:on_call_node_enter)
69
+ events << :on_call_node_leave if listener.respond_to?(:on_call_node_leave)
69
70
  events << :on_def_node_enter if listener.respond_to?(:on_def_node_enter)
70
71
  events << :on_singleton_class_node_enter if listener.respond_to?(:on_singleton_class_node_enter)
71
72
  events << :on_singleton_class_node_leave if listener.respond_to?(:on_singleton_class_node_leave)
@@ -5,6 +5,8 @@ module RailsAiContext
5
5
  # sending notifications to connected AI clients so they re-query fresh data.
6
6
  # Runs a background thread alongside the MCP server (stdio or HTTP).
7
7
  class LiveReload
8
+ include CountPhrase
9
+
8
10
  WATCH_DIRS = (Watcher::WATCH_PATTERNS | Fingerprinter::WATCHED_DIRS).freeze
9
11
 
10
12
  attr_reader :app, :mcp_server
@@ -83,12 +85,12 @@ module RailsAiContext
83
85
  when %r{app/views} then "view"
84
86
  when %r{app/jobs} then "job"
85
87
  when %r{app/mailers} then "mailer"
86
- when %r{app/javascript} then "javascript"
88
+ when %r{app/javascript} then "JavaScript file"
87
89
  when %r{config/routes} then "route"
88
90
  when %r{config/} then "config"
89
91
  when %r{db/migrate} then "migration"
90
92
  when %r{db/} then "database"
91
- when %r{lib/tasks} then "rake_task"
93
+ when %r{lib/tasks} then "rake task"
92
94
  else "file"
93
95
  end
94
96
 
@@ -98,9 +100,9 @@ module RailsAiContext
98
100
  categories
99
101
  end
100
102
 
101
- # Build a readable summary like "Files changed: 2 model(s), 1 controller(s)."
103
+ # Build a readable summary like "Files changed: 2 models, 1 controller."
102
104
  def format_change_message(categories)
103
- parts = categories.map { |cat, count| "#{count} #{cat}(s)" }
105
+ parts = categories.map { |cat, count| count_phrase(count, cat) }
104
106
  "Files changed: #{parts.join(", ")}."
105
107
  end
106
108
  end
@@ -43,7 +43,7 @@ module RailsAiContext
43
43
  # Compact counts - gems and architecture are already in the root file (CLAUDE.md/AGENTS.md)
44
44
  schema = context[:schema]
45
45
  if SectionGuard.usable?(schema)
46
- lines << "- Database: #{schema[:adapter]} - #{schema[:total_tables]} tables"
46
+ lines << "- Database: #{schema[:adapter]} - #{count_phrase(schema[:total_tables], "table")}"
47
47
  end
48
48
 
49
49
  models = context[:models]
@@ -133,7 +133,7 @@ module RailsAiContext
133
133
  .map { |i| i[:unique] ? "#{Array(i[:columns]).join('+')}(unique)" : Array(i[:columns]).join("+") }
134
134
  idx_str = idxs.any? ? " | Idx: #{idxs.join(', ')}" : ""
135
135
 
136
- lines << "- **#{name}** (#{col_count} cols)#{col_str}#{fk_str}#{idx_str}"
136
+ lines << "- **#{name}** (#{count_phrase(col_count, 'col')})#{col_str}#{fk_str}#{idx_str}"
137
137
 
138
138
  # Include enum values if model has them
139
139
  model_name = name.classify
@@ -146,7 +146,7 @@ module RailsAiContext
146
146
  end
147
147
 
148
148
  if tables.size > 30
149
- lines << "- ...#{tables.size - 30} more tables (use `rails_get_schema` MCP tool)"
149
+ lines << "- ...#{count_phrase(tables.size - 30, "more table")} (use `rails_get_schema` MCP tool)"
150
150
  end
151
151
 
152
152
  lines.join("\n")
@@ -191,7 +191,7 @@ module RailsAiContext
191
191
  table = data[:table_name]
192
192
  line = "- #{name}"
193
193
  line += " (table: #{table})" if table
194
- line += " - #{assocs} assocs, #{vals} validations"
194
+ line += " - #{count_phrase(assocs, "assoc")}, #{count_phrase(vals, "validation")}"
195
195
  lines << line
196
196
 
197
197
  # Include app-specific concerns (filter out Rails/gem internals)
@@ -6,6 +6,8 @@ module RailsAiContext
6
6
  # Include in any serializer that has a `context` reader and includes
7
7
  # StackOverviewHelper, ToolGuideHelper, and TestCommandDetection.
8
8
  module CompactSerializerHelper
9
+ include CountPhrase
10
+
9
11
  private
10
12
 
11
13
  def render_header
@@ -24,7 +26,7 @@ module RailsAiContext
24
26
  schema = context[:schema]
25
27
  if SectionGuard.usable?(schema)
26
28
  tables = schema[:total_tables]
27
- lines << "- Database: #{schema[:adapter]} - #{tables} #{tables == 1 ? 'table' : 'tables'}"
29
+ lines << "- Database: #{schema[:adapter]} - #{count_phrase(tables, "table")}"
28
30
  end
29
31
 
30
32
  models = context[:models]
@@ -41,8 +43,8 @@ module RailsAiContext
41
43
  # routes across app controllers, with the grand total (framework
42
44
  # routes included) alongside for scale.
43
45
  app_routes = app_ctrls.sum { |k| Array(by_controller[k]).size }
44
- lines << "- Routes: #{app_routes} app #{app_routes == 1 ? 'route' : 'routes'} across " \
45
- "#{app_ctrls.size} #{app_ctrls.size == 1 ? 'controller' : 'controllers'} " \
46
+ lines << "- Routes: #{count_phrase(app_routes, "app route")} across " \
47
+ "#{count_phrase(app_ctrls.size, "controller")} " \
46
48
  "(#{routes[:total_routes]} total incl. framework)"
47
49
  end
48
50
 
@@ -52,9 +54,9 @@ module RailsAiContext
52
54
  mailer_count = jobs[:mailers]&.size || 0
53
55
  channel_count = jobs[:channels]&.size || 0
54
56
  parts = []
55
- parts << "#{job_count} jobs" if job_count > 0
56
- parts << "#{mailer_count} mailers" if mailer_count > 0
57
- parts << "#{channel_count} channels" if channel_count > 0
57
+ parts << count_phrase(job_count, "job") if job_count > 0
58
+ parts << count_phrase(mailer_count, "mailer") if mailer_count > 0
59
+ parts << count_phrase(channel_count, "channel") if channel_count > 0
58
60
  lines << "- Async: #{parts.join(', ')}" if parts.any?
59
61
  end
60
62
 
@@ -45,7 +45,7 @@ module RailsAiContext
45
45
 
46
46
  schema = context[:schema]
47
47
  if SectionGuard.usable?(schema)
48
- lines << "- Database: #{schema[:adapter]} - #{schema[:total_tables]} tables"
48
+ lines << "- Database: #{schema[:adapter]} - #{count_phrase(schema[:total_tables], "table")}"
49
49
  end
50
50
 
51
51
  models = context[:models]
@@ -108,7 +108,7 @@ module RailsAiContext
108
108
  models.keys.sort.first(30).each do |name|
109
109
  data = models[name]
110
110
  assocs = (data[:associations] || []).size
111
- lines << "- #{name} (#{assocs} associations)"
111
+ lines << "- #{name} (#{count_phrase(assocs, "association")})"
112
112
  extras = model_extras_line(data)
113
113
  lines << extras if extras
114
114
  end
@@ -146,7 +146,7 @@ module RailsAiContext
146
146
  "---",
147
147
  "applyTo: \"**/*\"",
148
148
  "name: \"Rails MCP Tools\"",
149
- "description: \"#{tool_count} introspection tools - schema, models, routes, controllers, search, testing, validation\"",
149
+ "description: \"#{count_phrase(tool_count, "introspection tool")} - schema, models, routes, controllers, search, testing, validation\"",
150
150
  "excludeAgent: \"code-review\"",
151
151
  "---",
152
152
  ""
@@ -29,7 +29,7 @@ module RailsAiContext
29
29
  schema = context[:schema]
30
30
  if SectionGuard.usable?(schema)
31
31
  tables = schema[:total_tables]
32
- lines << "- Database: #{schema[:adapter]} - #{tables} #{tables == 1 ? 'table' : 'tables'}"
32
+ lines << "- Database: #{schema[:adapter]} - #{count_phrase(tables, "table")}"
33
33
  end
34
34
 
35
35
  models = context[:models]
@@ -43,8 +43,8 @@ module RailsAiContext
43
43
  by_controller = routes[:by_controller] || {}
44
44
  app_ctrls = by_controller.keys.reject { |k| internal.any? { |p| k.downcase.start_with?(p) } }
45
45
  app_routes = app_ctrls.sum { |k| Array(by_controller[k]).size }
46
- lines << "- Routes: #{app_routes} app #{app_routes == 1 ? 'route' : 'routes'} across " \
47
- "#{app_ctrls.size} #{app_ctrls.size == 1 ? 'controller' : 'controllers'} " \
46
+ lines << "- Routes: #{count_phrase(app_routes, "app route")} across " \
47
+ "#{count_phrase(app_ctrls.size, "controller")} " \
48
48
  "(#{routes[:total_routes]} total incl. framework)"
49
49
  end
50
50
 
@@ -74,7 +74,7 @@ module RailsAiContext
74
74
 
75
75
  schema = context[:schema]
76
76
  if SectionGuard.usable?(schema)
77
- lines << "- Database: #{schema[:adapter]} - #{schema[:total_tables]} tables"
77
+ lines << "- Database: #{schema[:adapter]} - #{count_phrase(schema[:total_tables], "table")}"
78
78
  end
79
79
 
80
80
  models = context[:models]
@@ -143,7 +143,7 @@ module RailsAiContext
143
143
  models.keys.sort.first(30).each do |name|
144
144
  data = models[name]
145
145
  assocs = (data[:associations] || []).size
146
- lines << "- #{name} (#{assocs} associations, table: #{data[:table_name] || '?'})"
146
+ lines << "- #{name} (#{count_phrase(assocs, "association")}, table: #{data[:table_name] || '?'})"
147
147
  extras = model_extras_line(data)
148
148
  lines << extras if extras
149
149
  end
@@ -185,7 +185,7 @@ module RailsAiContext
185
185
  def render_mcp_tools_rule
186
186
  lines = [
187
187
  "---",
188
- "description: \"Rails MCP tools reference - #{tool_count} tools for schema, models, routes, controllers, search, testing, and more\"",
188
+ "description: \"Rails MCP tools reference - #{count_phrase(tool_count, "tool")} for schema, models, routes, controllers, search, testing, and more\"",
189
189
  "alwaysApply: false",
190
190
  "---",
191
191
  ""