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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/exe/rails-ai-context +6 -8
- data/lib/generators/rails_ai_context/install/install_generator.rb +6 -6
- data/lib/rails_ai_context/count_phrase.rb +24 -0
- data/lib/rails_ai_context/doctor.rb +21 -17
- data/lib/rails_ai_context/facts_formatter.rb +5 -3
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +38 -21
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_introspector.rb +1 -0
- data/lib/rails_ai_context/live_reload.rb +6 -4
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +4 -4
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +4 -4
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +12 -10
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +5 -3
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +2 -2
- data/lib/rails_ai_context/tools/analyze_feature.rb +5 -5
- data/lib/rails_ai_context/tools/base_tool.rb +2 -0
- data/lib/rails_ai_context/tools/get_active_support.rb +1 -1
- data/lib/rails_ai_context/tools/get_api.rb +3 -3
- data/lib/rails_ai_context/tools/get_callbacks.rb +2 -2
- data/lib/rails_ai_context/tools/get_component_catalog.rb +2 -2
- data/lib/rails_ai_context/tools/get_concern.rb +3 -3
- data/lib/rails_ai_context/tools/get_controllers.rb +1 -1
- data/lib/rails_ai_context/tools/get_conventions.rb +1 -1
- data/lib/rails_ai_context/tools/get_edit_context.rb +1 -1
- data/lib/rails_ai_context/tools/get_engines.rb +2 -2
- data/lib/rails_ai_context/tools/get_env_config.rb +1 -1
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +6 -6
- data/lib/rails_ai_context/tools/get_helper_methods.rb +2 -2
- data/lib/rails_ai_context/tools/get_i18n.rb +10 -3
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +2 -2
- data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -3
- data/lib/rails_ai_context/tools/get_routes.rb +5 -5
- data/lib/rails_ai_context/tools/get_schema.rb +6 -6
- data/lib/rails_ai_context/tools/get_service_pattern.rb +3 -3
- data/lib/rails_ai_context/tools/get_stimulus.rb +5 -5
- data/lib/rails_ai_context/tools/get_test_info.rb +8 -8
- data/lib/rails_ai_context/tools/get_view.rb +11 -11
- data/lib/rails_ai_context/tools/onboard.rb +5 -10
- data/lib/rails_ai_context/tools/query.rb +1 -1
- data/lib/rails_ai_context/tools/read_logs.rb +1 -1
- data/lib/rails_ai_context/tools/review_changes.rb +3 -3
- data/lib/rails_ai_context/tools/runtime_info.rb +3 -3
- data/lib/rails_ai_context/tools/search_code.rb +5 -5
- data/lib/rails_ai_context/tools/search_docs.rb +2 -2
- data/lib/rails_ai_context/tools/security_scan.rb +9 -4
- data/lib/rails_ai_context/tools/session_context.rb +2 -2
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 175ccbf5bac4703a8f9699708a02435a9b80546c09129deae79ffb5f182e14e6
|
|
4
|
+
data.tar.gz: 0adbdf439e6ac853606fc786d6f92041a70aec4e2379ebfe98132eb14352b219
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 #{
|
|
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
|
|
575
|
-
if defined?(::RailsAiContext::Server)
|
|
576
|
-
|
|
577
|
-
|
|
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}
|
|
219
|
-
# :standard - #{RailsAiContext::Configuration::PRESETS[:standard].size
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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}
|
|
405
|
-
"(these feed the #{Server.builtin_tools.size
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
45
|
-
"#{indexes
|
|
46
|
-
"#{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
|
|
138
|
+
Array(paths).map { |p|
|
|
138
139
|
s = p.to_s
|
|
139
140
|
s.start_with?(root) ? s.sub("#{root}/", "") : s
|
|
140
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
86
|
+
default_keys = key_paths_for_locale(I18n.default_locale)
|
|
85
87
|
locales.reject { |l| l == I18n.default_locale }.each do |locale|
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
109
|
+
next [] unless content
|
|
101
110
|
data = YAML.safe_load(content, permitted_classes: [ Symbol ])
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
end
|
|
118
|
+
[]
|
|
119
|
+
end.uniq
|
|
106
120
|
rescue => e
|
|
107
|
-
$stderr.puts "[rails-ai-context]
|
|
108
|
-
|
|
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
|
|
128
|
-
return
|
|
129
|
-
hash.
|
|
130
|
-
|
|
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
|
|
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 "
|
|
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 "
|
|
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
|
|
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|
|
|
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]
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
45
|
-
"#{app_ctrls.size
|
|
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 <<
|
|
56
|
-
parts <<
|
|
57
|
-
parts <<
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
47
|
-
"#{app_ctrls.size
|
|
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]
|
|
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}
|
|
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}
|
|
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
|
""
|