rails-ai-context 5.30.1 → 5.30.2

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/docs/CLI.md +1 -1
  4. data/docs/COMPATIBILITY.md +3 -1
  5. data/docs/CONFIGURATION.md +2 -2
  6. data/docs/FAQ.md +1 -1
  7. data/docs/GUIDE.md +1 -1
  8. data/exe/rails-ai-context +3 -4
  9. data/lib/rails_ai_context/action_filters.rb +69 -6
  10. data/lib/rails_ai_context/boot_manager.rb +21 -5
  11. data/lib/rails_ai_context/cli/entry_boot.rb +3 -2
  12. data/lib/rails_ai_context/cli/tool_runner.rb +22 -3
  13. data/lib/rails_ai_context/concern_paths.rb +2 -1
  14. data/lib/rails_ai_context/configuration.rb +6 -6
  15. data/lib/rails_ai_context/erb_source.rb +22 -3
  16. data/lib/rails_ai_context/introspector.rb +13 -2
  17. data/lib/rails_ai_context/introspectors/controller_introspector.rb +43 -16
  18. data/lib/rails_ai_context/introspectors/eager_load.rb +22 -10
  19. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +4 -3
  20. data/lib/rails_ai_context/introspectors/job_introspector.rb +24 -10
  21. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +12 -0
  22. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +9 -1
  23. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +13 -6
  24. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +81 -25
  25. data/lib/rails_ai_context/introspectors/listeners/with_options_scope.rb +49 -12
  26. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +15 -0
  27. data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -0
  28. data/lib/rails_ai_context/introspectors/model_introspector.rb +14 -3
  29. data/lib/rails_ai_context/introspectors/route_introspector.rb +62 -4
  30. data/lib/rails_ai_context/introspectors/schema_conventions.rb +8 -0
  31. data/lib/rails_ai_context/introspectors/schema_introspector.rb +7 -4
  32. data/lib/rails_ai_context/introspectors/schema_reader.rb +2 -0
  33. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +5 -4
  34. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +18 -16
  35. data/lib/rails_ai_context/migration_status.rb +6 -1
  36. data/lib/rails_ai_context/pending_migrations.rb +3 -2
  37. data/lib/rails_ai_context/portable_path.rb +14 -0
  38. data/lib/rails_ai_context/schema_adapter.rb +6 -1
  39. data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -2
  40. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -0
  41. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
  42. data/lib/rails_ai_context/tools/base_tool.rb +9 -8
  43. data/lib/rails_ai_context/tools/diagnose.rb +4 -0
  44. data/lib/rails_ai_context/tools/generate_test.rb +19 -0
  45. data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
  46. data/lib/rails_ai_context/tools/get_partial_interface.rb +18 -4
  47. data/lib/rails_ai_context/tools/get_routes.rb +19 -9
  48. data/lib/rails_ai_context/tools/get_schema.rb +22 -4
  49. data/lib/rails_ai_context/tools/get_service_pattern.rb +44 -25
  50. data/lib/rails_ai_context/tools/get_test_info.rb +8 -0
  51. data/lib/rails_ai_context/tools/query.rb +3 -5
  52. data/lib/rails_ai_context/tools/runtime_info.rb +5 -3
  53. data/lib/rails_ai_context/tools/validate_semantics.rb +49 -13
  54. data/lib/rails_ai_context/version.rb +1 -1
  55. data/server.json +1 -1
  56. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e31095f85a3f90825fbd66bf2a7deae9224ac4fe94474c8d60da20c46ed52b47
4
- data.tar.gz: 190a7d48230b4ae98300b2619a0d2cf7a1ddb8a6e24619a7a54cfa3a6e5b233b
3
+ metadata.gz: 49493277fcbe8b2da2d183de723ebf3d5f4c85101f5eff5b6a0c89a1ae4340a9
4
+ data.tar.gz: ebd28661e4d369b526027b8705c599372383796003287160a460ec3e99990632
5
5
  SHA512:
6
- metadata.gz: f91198dc48ae6f259c0f4d170b0fbae5d2174fa142a64530ef4ac46f2e6c51c9e22e54057e7f64712f06fc76a085e45994da2a9dc592edfe9ee3f0366608d642
7
- data.tar.gz: cee2a7a2d636322fddeedcffa151812d0ae8c52cde17d99394fd7dded5e30e5bddb8db288bc9b31b0fe1281a9a33270739c000735957fab6d64cbafdfec8a149
6
+ metadata.gz: 3a98be099ee6b23ded854a1820714d9450d8ce304dd08c18d12182b7a8f15097f0ca5eb2c3c0db122b313057aed0a9ec3b44b38aae1c0e1a231d1f41b455c6e6
7
+ data.tar.gz: 0be3021416fc068c9e93bfaabba548a22cb914c6393f1daf6ef9a7892045cf1984efad3d22de132c29b3d83081e796a5f74e868fc8b4f94883912ff7f4562f75
data/CHANGELOG.md CHANGED
@@ -5,6 +5,128 @@ 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.30.2] - 2026-10-01
9
+
10
+ ### Changed
11
+
12
+ - **`generate_root_files: false` leaves out `.cursorrules` too, and always
13
+ writes `.ai-context.json`.** `.cursorrules` is the same compact file as
14
+ `CLAUDE.md` at the repo root, so it is a root file and now reports "Not
15
+ applicable (root files disabled)". `.ai-context.json` is data for tools, not
16
+ an instruction file, and is written whatever the key says.
17
+ - **`serve --port` has no default of its own.** An explicit `--port` wins, then
18
+ `http_port` from the config, then 6029. The config key never reached `serve`
19
+ before, although `docs/STANDALONE.md` documents it, and the
20
+ `streamable_http` alias ignored `--port`.
21
+ - **A PostGIS connection is labelled PostgreSQL everywhere.** The booted schema
22
+ said PostGIS and the static tier PostgreSQL for the same app. The raw adapter
23
+ name stays in `.ai-context.json` as `adapter_source`.
24
+ - **The booted schema header lists pending migrations**, as the static tier
25
+ already did.
26
+
27
+ ### Fixed
28
+
29
+ - **No machine path in an introspector's error.** A model file that could not
30
+ load put the absolute path of the app into `rails_get_model_details`,
31
+ `CLAUDE.md`, the models rules file and `.ai-context.json`, and the controller,
32
+ component, middleware, migration and rake-task errors did the same. Every
33
+ section's errors are now written relative to the app root, the resolved
34
+ `/private/tmp` form included.
35
+ - **A declared, unmigrated table names the migration that adds it.** The booted
36
+ answer said the table was missing from the database and stopped there. Rails
37
+ 7.2+ refuses to load a migration dated in the future, which also emptied the
38
+ pending list; the list now comes from the applied versions and the files.
39
+ - **An unreadable model file still counts as the model file for its table**,
40
+ instead of listing the table under "Tables with no model file".
41
+ - **The static tier carries each `belongs_to` foreign key.** Static
42
+ association records had none unless the source spelled one, so
43
+ `rails_get_model_details`, the context files and `.ai-context.json` left it
44
+ out, and `rails_validate` stayed silent on an unindexed key where the booted
45
+ tier warned. A polymorphic key indexed as `[type, id]` counts as indexed.
46
+ - **A composite foreign key in `db/structure.sql` is read**, pg_dump and
47
+ mysqldump both, and renders as a column list like the schema.rb one.
48
+ - **Minitest tests from `rails_generate_test` can pass.** Absence,
49
+ confirmation, acceptance and exclusion validations asserted on a valid
50
+ fixture; each now sets up an invalid record, or emits `skip` where it cannot.
51
+ - **A mount at a computed path prints no path.** `mount X => ENV.fetch(...)`
52
+ printed `` at `[INFERRED]` ``, a bare constant printed its name as the path,
53
+ and a gem engine mounted that way read as "not mounted by the app's routes".
54
+ A path written without its leading slash gets one, as Rails does.
55
+ - **Static routes say which engine tables they could not read.** An engine
56
+ whose routes live in its gem (PgHero, LetterOpenerWeb) dropped out of the
57
+ static answer with no note; it is listed under "Mounted engines whose routes
58
+ were not read". A constant the app defines counts as an engine only when it
59
+ subclasses `Rails::Engine`. An engine mounted at several paths is one line
60
+ naming each.
61
+ - **Booted filter attribution matches the static tier.** An inherited
62
+ `after_action` lost its "from" credit when the child declared a
63
+ `before_action` of the same name, filters from a controller concern were not
64
+ credited to it, and a concern's `only:`/`except:`/`if:`/`unless:` and its
65
+ `skip_*_action` calls never reached the booted record. A body that
66
+ re-declares a concern's filter owns it, and a concern an ancestor already
67
+ includes stays the ancestor's.
68
+ - **An ERB comment is not read for instance variables**, while a Ruby comment
69
+ line inside a multi-line code tag no longer hides the code after it.
70
+ - **`rails_get_partial_interface` finds `render(partial: ...)`** with
71
+ parentheses, on one line or several, counts a nested render once, and a
72
+ multi-line snippet stops at the call's end.
73
+ - **Jobs on Rails 7.0 to 8.1.** A job with no `queue_as` read as "computed by a
74
+ block", since ActiveJob's default queue is a lambda; it now shows the
75
+ resolved default. The booted tier dropped every `retry_on`/`discard_on`, and
76
+ a job in a code root Zeitwerk does not manage.
77
+ - **A mailer outside `app/mailers` is a mailer in the booted tier too**, and
78
+ `rails_get_service_pattern` no longer answers a mailer, concern or mixin by
79
+ name when its listing leaves them out.
80
+ - **`app/concerns` reads as `other`** when the app root directory is itself
81
+ named `app`, as in Docker's `/app`.
82
+ - **The static default locale reads `Rails.application.config.i18n`** in an
83
+ initializer, where the last assignment wins.
84
+ - **A bad value for a required enum fails the call.**
85
+ `migration_advisor --action bogus` said "action is required" and exited 0;
86
+ it says `bogus` is not a valid `action`, names the allowed values and exits 1.
87
+ - **`rails_diagnose` never suggests a path it refused**, refuses it with or
88
+ without `--line`, and prints the tier footer once.
89
+ - **`rails_validate` names a `belongs_to` whose key column the table lacks**
90
+ instead of calling that column unindexed, and says nothing about a table's
91
+ indexes when its schema calls a helper the reader cannot interpret (Canvas's
92
+ `t.replica_identity_index`), since that helper may add the index.
93
+ - **A multi-name `t.references :a, :b, :c` adds every column**, with its index
94
+ and foreign key, in the schema.rb reader and the migration replay. The replay
95
+ also records `foreign_key: true` on a single reference, which it dropped.
96
+ - **A declaration inside a block the model may not run is kept but not
97
+ trusted.** `has_details_table do belongs_to :parent end` put a `parent_id`
98
+ key on the model's own table, and `rails_validate` checked it there. Such
99
+ declarations are still listed, and `rails_validate`'s schema, column and
100
+ `:dependent` checks skip them. `with_options`, `included`, `class_methods`,
101
+ `concerning`, `state_machine` and `aasm` blocks run on the model and are
102
+ trusted as before.
103
+ - **A foreign key given as a constant or an expression is not read as a column
104
+ name**, and a composite key stays a column list in both tiers instead of the
105
+ text of a Ruby array.
106
+ - **Every column type reads as a column in the static schema**, `t.vector`,
107
+ `t.citext`, `t.enum`, the range and PostGIS types included, and so does a
108
+ column whose options sit in braces. They were left out of the table.
109
+ - **`attr_reader` and `alias_attribute` names count as attributes** for
110
+ `rails_validate`'s "column not found" check.
111
+ - **Filters inherited through a Devise or Doorkeeper controller.** The static
112
+ walk stopped at the gem class, so a controller under `Devise::` or
113
+ `Doorkeeper::` lost every filter its app base declares and was credited with
114
+ a concern that base already includes. It now continues at the base the gem's
115
+ initializer names (`parent_controller`, `base_controller`).
116
+ - **In-repo route files are read statically.** A mounted in-repo engine's own
117
+ routes are listed under its mount, and a file that draws, appends or prepends
118
+ to the app's routes adds to the app's table; only an unmounted engine's file
119
+ still counts as not read.
120
+ - **A failed boot names missing gems once.** A bundle missing many gems listed
121
+ every name three times; the banner now gives the count and the first few,
122
+ and `doctor` and `DEBUG=1` keep the full list.
123
+ - **A test file over `max_test_file_size` is named as too large**, not missing.
124
+ - **`rails_runtime_info` says how many rows it left out** of the unused-index
125
+ list and the summary table sizes, which it cut at 10 and 5 with no note.
126
+ - **`rails_query` on MySQL** reads a `MAX_EXECUTION_TIME` interruption as the
127
+ timeout, and names `INTO OUTFILE`/`DUMPFILE` as a disk write.
128
+ - The defaults in `configuration.rb`'s comments match the values it sets.
129
+
8
130
  ## [5.30.1] - 2026-09-28
9
131
 
10
132
  ### Fixed
data/docs/CLI.md CHANGED
@@ -36,7 +36,7 @@ rails-ai-context serve --transport http --port 6029 # HTTP transport
36
36
  | Option | Default | Description |
37
37
  |:-------|:--------|:------------|
38
38
  | `--transport` | `stdio` | `stdio` or `http` |
39
- | `--port` | `6029` | HTTP listen port |
39
+ | `--port` | `http_port` from config, else `6029` | HTTP listen port |
40
40
  | `--no-boot` | off | Skip booting the app; answer from source alone |
41
41
 
42
42
  ### `tool`
@@ -215,7 +215,9 @@ Proof sources:
215
215
  PostgreSQL 17 with a partitioned table added, booted and static from
216
216
  schema.rb and structure.sql, a Rails 7.2 app whose schema.rb carries no
217
217
  partition marker, and Discourse, Mastodon, OpenFoodNetwork and Forem
218
- answering as before.
218
+ answering as before. In the v5.30.2 release QA: 14 real apps answered by
219
+ v5.30.1 and by the release branch side by side, and the lab regressions on
220
+ SQLite in all three tiers, PostgreSQL and MySQL.
219
221
  2. Non-crash coverage for every built-in tool including `get_view` in
220
222
  `spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
221
223
  (ivar cross-check, render-form detection, partial interfaces) verified
@@ -86,7 +86,7 @@ config file is written. Before v5.27.0 an empty list wrote every tool's files.
86
86
  | `preset` | Symbol | `:full` | `:full` (40 introspectors) or `:standard` (17 introspectors) |
87
87
  | `context_mode` | Symbol | `:compact` | `:compact` (context files capped at ~150 lines) or `:full` (no line cap) |
88
88
  | `introspectors` | Array of symbols | (from preset) | Override the introspector list directly |
89
- | `generate_root_files` | Boolean | `true` | Set `false` to generate split rules only, no root CLAUDE.md/AGENTS.md |
89
+ | `generate_root_files` | Boolean | `true` | Set `false` to generate split rules only: no CLAUDE.md, AGENTS.md, .cursorrules or copilot-instructions.md (`.ai-context.json` is still written) |
90
90
  | `anti_hallucination_rules` | Boolean | `true` | Embed 6-rule verification protocol in generated context files |
91
91
  | `claude_max_lines` | Integer | `150` | Max non-blank lines in a compact context file's gem-managed block, the `<!-- BEGIN/END rails-ai-context -->` markers included. Over budget, data lines are cut and the Commands, Warnings, Rules and MCP-tools sections kept whole |
92
92
 
@@ -247,7 +247,7 @@ Lightweight subset for faster generation:
247
247
  | AI Tool | Root File | Split Rules | MCP Config |
248
248
  |:--------|:----------|:------------|:-----------|
249
249
  | Claude Code | `CLAUDE.md` | `.claude/rules/*.md` | `.mcp.json` |
250
- | Cursor | - | `.cursor/rules/*.mdc` | `.cursor/mcp.json` |
250
+ | Cursor | `.cursorrules` (legacy fallback) | `.cursor/rules/*.mdc` | `.cursor/mcp.json` |
251
251
  | GitHub Copilot | `.github/copilot-instructions.md` | `.github/instructions/*.instructions.md` | `.vscode/mcp.json` |
252
252
  | OpenCode | `AGENTS.md` | `app/*/AGENTS.md` | `opencode.json` |
253
253
  | Codex CLI | (shares `AGENTS.md`) | (shares OpenCode rules) | `.codex/config.toml` |
data/docs/FAQ.md CHANGED
@@ -126,7 +126,7 @@ Yes. The file is applied first and a `configure` block wins only the keys it ass
126
126
 
127
127
  ### What's `generate_root_files`?
128
128
 
129
- When `true` (default), generates root files like CLAUDE.md, AGENTS.md. Set to `false` to only generate split rules (.claude/rules/, .cursor/rules/, etc.).
129
+ When `true` (default), generates the root files: CLAUDE.md, AGENTS.md, .cursorrules and copilot-instructions.md. Set to `false` to only generate split rules (.claude/rules/, .cursor/rules/, etc.). `.ai-context.json` is written either way.
130
130
 
131
131
  ---
132
132
 
data/docs/GUIDE.md CHANGED
@@ -1353,7 +1353,7 @@ end
1353
1353
  | `live_reload_debounce` | Float | `1.5` | Debounce interval in seconds for live reload |
1354
1354
  | `server_name` | String | `"rails-ai-context"` | MCP server name |
1355
1355
  | `server_version` | String | gem version | MCP server version |
1356
- | `generate_root_files` | Boolean | `true` | Generate root files (CLAUDE.md, etc.) - set `false` for split rules only |
1356
+ | `generate_root_files` | Boolean | `true` | Set `false` to generate split rules only: no CLAUDE.md, AGENTS.md, .cursorrules or copilot-instructions.md (`.ai-context.json` is still written) |
1357
1357
  | `anti_hallucination_rules` | Boolean | `true` | Embed 6-rule Anti-Hallucination Protocol in generated context files - set `false` to skip |
1358
1358
  | `hydration_enabled` | Boolean | `true` | Inject schema hints into controller/view tool responses |
1359
1359
  | `hydration_max_hints` | Integer | `5` | Max schema hints per tool response |
data/exe/rails-ai-context CHANGED
@@ -175,15 +175,14 @@ class RailsAiContextCLI < Thor
175
175
 
176
176
  desc "serve", "Start MCP server (stdio transport)"
177
177
  option :transport, type: :string, default: "stdio", desc: "Transport: stdio or http"
178
- option :port, type: :numeric, default: 6029, desc: "HTTP port (only for http transport)"
178
+ option :port, type: :numeric, desc: "HTTP port (only for http transport; default: config http_port, else 6029)"
179
179
  option :no_boot, type: :boolean, default: false, desc: "Skip booting the app; serve static analysis only"
180
180
  def serve
181
181
  boot_rails!(command: "serve", allow_static: true)
182
182
 
183
183
  transport = options[:transport].to_sym
184
- if transport == :http
185
- ::RailsAiContext.configuration.http_port = options[:port]
186
- end
184
+ # No Thor default: it would overwrite the config file's http_port.
185
+ ::RailsAiContext.configuration.http_port = options[:port] if options[:port]
187
186
 
188
187
  ::RailsAiContext.start_mcp_server(transport: transport)
189
188
  end
@@ -58,19 +58,25 @@ module RailsAiContext
58
58
  # The whole ancestor entry, not only its `from:`: an entry the walk
59
59
  # could not attribute carries `provenance` instead, and merging only
60
60
  # `from` dropped that on the tier the label exists for.
61
- attribution_of = parent.to_h { |f| [ entry_key(f), f.slice(:from, :provenance) ] }
61
+ attribution_of = parent.to_h { |f| [ entry_key(f), f.slice(:from, :from_concern, :provenance) ] }
62
62
  declared_on = attribution_of
63
63
  declared_names = declared.map { |f| entry_key(f) }.to_set
64
64
 
65
65
  # A filter this body declares is its own, whatever an ancestor declares
66
66
  # as well. The runtime tier's list also carries names it only inherits,
67
67
  # and those are the ones that move: they carry no `declared` mark.
68
- inherited_here = ->(f) { declared_on.key?(entry_key(f)) && !f[:declared] }
68
+ # An ActiveSupport::Concern an ancestor already includes does not run again, so its filters stay the ancestor's.
69
+ inherited_here = lambda do |f|
70
+ next false unless declared_on.key?(entry_key(f))
71
+
72
+ !f[:declared] || (f[:from_concern] && declared_on[entry_key(f)][:from_concern] == f[:from_concern] &&
73
+ runs_once?(f[:from_concern], root, controller_name))
74
+ end
69
75
  own = mark_conditional_skips(applicable.reject(&inherited_here), conditions, action)
70
76
  inherited = mark_conditional_skips(
71
77
  parent.reject { |f| declared_names.include?(entry_key(f)) } +
72
78
  applicable.select(&inherited_here)
73
- .map { |f| f.except(:from, :provenance).merge(attribution_of[entry_key(f)]) }, conditions, action
79
+ .map { |f| f.except(:from, :from_concern, :provenance).merge(attribution_of[entry_key(f)]) }, conditions, action
74
80
  )
75
81
 
76
82
  { own: own,
@@ -196,7 +202,10 @@ module RailsAiContext
196
202
  info = controllers[name]
197
203
  source = info.is_a?(Hash) ? nil : base_controller_source(name, root)
198
204
  info ||= { filters: base_filters(source, name, root) } if source
199
- break unless info.is_a?(Hash)
205
+ unless info.is_a?(Hash)
206
+ name = gem_controller_base(name, root)
207
+ next
208
+ end
200
209
 
201
210
  # The class that skips a filter must not contribute it either: in the
202
211
  # booted tier its own list is the reflection list, which carries every
@@ -234,7 +243,9 @@ module RailsAiContext
234
243
  evidence = {}, body_known = false)
235
244
  key = entry_key(filter)
236
245
  if found.key?(key)
237
- found[key] = found[key].merge(from: ancestor) if filter[:declared] && !attributed.include?(key)
246
+ if filter[:declared] && !attributed.include?(key)
247
+ found[key] = found[key].except(:from_concern).merge(filter.slice(:from_concern), from: ancestor)
248
+ end
238
249
  else
239
250
  found[key] = filter.merge(from: ancestor)
240
251
  positions[key] = depth
@@ -363,6 +374,58 @@ module RailsAiContext
363
374
  path && SafeFile.read(path)
364
375
  end
365
376
 
377
+ # Gem controllers whose base class is set in the app's initializers:
378
+ # [gem base, setting that names its parent, the gem's default parent].
379
+ GEM_CONTROLLER_BASES = [
380
+ [ /\ADevise(::|Controller\z)/, "parent_controller=", "ApplicationController" ],
381
+ [ /\ADoorkeeper::(Tokens|TokenInfo|ApplicationMetal)Controller\z/, "base_metal_controller", "ActionController::API" ],
382
+ [ /\ADoorkeeper::/, "base_controller", "ActionController::Base" ]
383
+ ].freeze
384
+
385
+ # The static tier cannot read a gem's class chain, but these gems end it
386
+ # at a class the app names, so the walk resumes there.
387
+ def gem_controller_base(name, root)
388
+ _, setting, default = GEM_CONTROLLER_BASES.find { |pattern, _, _| name.to_s.match?(pattern) }
389
+ return nil unless setting
390
+
391
+ root ||= default_root
392
+ configured = root && RunCache.fetch([ :gem_controller_base, root.to_s, setting ]) { configured_base(root, setting) }
393
+ return nil if configured == :computed
394
+
395
+ (configured || default).delete_prefix("::")
396
+ rescue => e
397
+ RailsAiContext.debug_fail(e, nil, label: "ActionFilters gem_controller_base")
398
+ end
399
+
400
+ # ActiveSupport::Concern skips a re-include; a plain module's `self.included` runs on each.
401
+ # A module whose file is not found is taken for a concern.
402
+ def runs_once?(name, root, within)
403
+ root ||= default_root
404
+ path = root && ConcernPaths.find_file(root.to_s, name, prefer: "controller", within: within.to_s)
405
+ source = path && SafeFile.read(path)
406
+ return true unless source
407
+
408
+ RunCache.fetch([ :concern_module, path ]) do
409
+ Introspectors::SourceIntrospector.walk_source(source, { mixins: Introspectors::Listeners::MixinsListener })[:mixins]
410
+ .any? { |mixin| mixin[:macro] == :extend && mixin[:name] == "ActiveSupport::Concern" }
411
+ end
412
+ end
413
+
414
+ # The last value the initializers set; :computed when it is not a string literal.
415
+ def configured_base(root, setting)
416
+ Dir.glob(File.join(root.to_s, "config", "initializers", "**", "*.rb")).sort.filter_map do |path|
417
+ source = SafeFile.read(path)
418
+ next unless source&.include?(setting.delete_suffix("="))
419
+
420
+ tree = AstCache.parse_string(source)&.value
421
+ call = tree && Introspectors::AstWalk.each(tree).select { |node| node.is_a?(Prism::CallNode) && node.name == setting.to_sym }.last
422
+ next unless call
423
+
424
+ value = call.arguments&.arguments&.first
425
+ value.is_a?(Prism::StringNode) ? value.unescaped : :computed
426
+ end.last
427
+ end
428
+
366
429
  def base_filters(source, name, root)
367
430
  Introspectors::ControllerFilters.with_concerns(source, root: (root || default_root).to_s, within: name.to_s).first
368
431
  end
@@ -392,6 +455,6 @@ module RailsAiContext
392
455
  :skip_calls, :base_filters, :skip_flag_records, :redeclared_names, :last_records, :own_skips,
393
456
  :record_attribution, :conditional?, :partial?, :absolute_names, :conditions_by_name,
394
457
  :merge_conditions, :mark_conditional_skips, :skip_tail, :action_names, :condition_text,
395
- :unplaced_conditional_skips, :evidence_skips
458
+ :unplaced_conditional_skips, :evidence_skips, :gem_controller_base, :configured_base, :runs_once?
396
459
  end
397
460
  end
@@ -31,6 +31,20 @@ module RailsAiContext
31
31
 
32
32
  DEFAULT_TIMEOUT = 60
33
33
 
34
+ # Bundler names every gem the bundle lacks; an app missing its whole bundle
35
+ # is thousands of characters. DEBUG keeps the full list.
36
+ MISSING_GEMS = /\ACould not find ((?:[^\s,]+, )+[^\s,]+) in (.+)\z/
37
+ MISSING_GEMS_SHOWN = 3
38
+
39
+ def self.shorten_missing_gems(line)
40
+ match = line.to_s.match(MISSING_GEMS)
41
+ return line if match.nil? || ENV["DEBUG"]
42
+
43
+ gems = match[1].split(", ").uniq
44
+ listed = gems.size > MISSING_GEMS_SHOWN ? "#{gems.size} gems (first few: #{gems.first(MISSING_GEMS_SHOWN).join(', ')})" : gems.join(", ")
45
+ "Could not find #{listed} in #{match[2]}"
46
+ end
47
+
34
48
  # Ruby quotes the method name differently across versions.
35
49
  CONFIGURE_WITHOUT_GEM = /undefined method .?configure.? for (module )?RailsAiContext/
36
50
 
@@ -55,16 +69,18 @@ module RailsAiContext
55
69
  # One-line summary safe to relay to an AI client or a terminal. It
56
70
  # carries the cause too: the footer this feeds is the only place most
57
71
  # callers ever see, and a gem name with no reason is not actionable.
58
- def failure_summary
72
+ # `full:` keeps a long missing-gem list whole, for a command that stops on the failure.
73
+ def failure_summary(full: false)
59
74
  return nil if booted?
60
75
 
61
- summary = one_line(error)
76
+ summary = one_line(error, full)
62
77
  cause = root_cause
63
- cause ? "#{summary} (cause: #{one_line(cause)})" : summary
78
+ cause ? "#{summary} (cause: #{one_line(cause, full)})" : summary
64
79
  end
65
80
 
66
- def one_line(e)
67
- "#{e.class}: #{e.message.to_s.lines.first&.strip}"
81
+ def one_line(e, full = false)
82
+ line = e.message.to_s.lines.first&.strip
83
+ "#{e.class}: #{full ? line : BootManager.shorten_missing_gems(line)}"
68
84
  end
69
85
 
70
86
  # An unguarded `RailsAiContext.configure` in config/initializers has
@@ -131,7 +131,7 @@ module RailsAiContext
131
131
 
132
132
  def self.boot_failed(result, root, timeout, messages)
133
133
  messages << "Error: Rails app failed to boot in #{root}"
134
- messages << " #{result.failure_summary}"
134
+ messages << " #{result.failure_summary(full: true)}"
135
135
  if ENV["DEBUG"]
136
136
  Array(result.error.backtrace).first(15).each { |line| messages << " #{line}" }
137
137
  # The wrapper's frames stop at the require; the frames that name the
@@ -170,7 +170,8 @@ module RailsAiContext
170
170
  RailsAiContext.static_kind = kind
171
171
  RailsAiContext.configuration.app_root = root
172
172
  Configuration.auto_load!(root)
173
- messages << "[rails-ai-context] static tier active: #{reason}"
173
+ # A failed boot's reason is already the line above.
174
+ messages << "[rails-ai-context] static tier active#{": #{reason}" unless kind == :boot_failed}"
174
175
  Outcome.new(tier: :static, reason: reason, kind: kind, messages: messages)
175
176
  rescue StandardError, ScriptError => e
176
177
  messages << "Error: #{e.message}"
@@ -31,6 +31,7 @@ module RailsAiContext
31
31
  @environment = environment
32
32
  @error = false
33
33
  @missing_required = false
34
+ @invalid_required = []
34
35
  @out_of_type = {}
35
36
  end
36
37
 
@@ -38,6 +39,8 @@ module RailsAiContext
38
39
  kwargs = build_kwargs
39
40
  schema = tool_schema
40
41
  validate_kwargs!(kwargs, schema)
42
+ return extract_output(invalid_required_response) if @invalid_required.any?
43
+
41
44
  response = tool_class.call(**kwargs)
42
45
  extract_output(response)
43
46
  end
@@ -418,7 +421,9 @@ module RailsAiContext
418
421
  end
419
422
 
420
423
  # Check enum constraints - downcase before comparing for case-insensitive match.
421
- # If invalid, strip the param so the tool uses its default behavior.
424
+ # If invalid, strip the param so the tool uses its default behavior; a
425
+ # required one has no default, so the call answers with the error itself
426
+ # rather than letting the tool report the value as missing.
422
427
  kwargs.each do |key, value|
423
428
  prop = properties[key]
424
429
  next unless prop&.dig(:enum)
@@ -431,12 +436,26 @@ module RailsAiContext
431
436
  if matched
432
437
  kwargs[key] = matched
433
438
  else
434
- $stderr.puts "Warning: '#{value}' is not a valid value for #{key}. Valid: #{prop[:enum].join(', ')}. Using default."
435
- kwargs.delete(key)
439
+ valid = prop[:enum].join(", ")
440
+ if required.include?(key.to_s)
441
+ @invalid_required << "**Error:** `#{value}` is not a valid `#{key}`. Valid: #{valid}"
442
+ @missing_required = true
443
+ else
444
+ $stderr.puts "Warning: '#{value}' is not a valid value for #{key}. Valid: #{valid}. Using default."
445
+ kwargs.delete(key)
446
+ end
436
447
  end
437
448
  end
438
449
  end
439
450
 
451
+ # Through the tool's own wrapper when it has one, so the static-tier banner rides along.
452
+ def invalid_required_response
453
+ text = @invalid_required.join("\n")
454
+ return tool_class.text_response(text) if tool_class <= Tools::BaseTool
455
+
456
+ MCP::Tool::Response.new([ { type: "text", text: text } ])
457
+ end
458
+
440
459
  def blank?(value)
441
460
  return true if value.nil?
442
461
  return value.strip.empty? if value.is_a?(String)
@@ -107,7 +107,8 @@ module RailsAiContext
107
107
  # concerns. Singular so a filter reads `type: "mailer"`.
108
108
  def type_for(dir)
109
109
  segment = dir[%r{/app/([^/]+)/concerns/?\z}, 1]
110
- segment ? segment.singularize : "other"
110
+ # An app root named app (Docker's /app) puts app/concerns at app/app/concerns.
111
+ segment && segment != "app" ? segment.singularize : "other"
111
112
  end
112
113
 
113
114
  # The path only approximates the declared constant: an app inflection makes
@@ -220,7 +220,7 @@ module RailsAiContext
220
220
  # Models/tables to exclude from introspection
221
221
  attr_accessor :excluded_models
222
222
 
223
- # TTL in seconds for cached introspection (default: 30)
223
+ # TTL in seconds for cached introspection (default: 60)
224
224
  attr_accessor :cache_ttl
225
225
 
226
226
  # Context file generation mode
@@ -262,8 +262,8 @@ module RailsAiContext
262
262
  # `generate_root_files`, which still writes the split rule files.
263
263
  attr_accessor :context_files
264
264
 
265
- # Whether to generate root-level context files (CLAUDE.md, AGENTS.md, etc.)
266
- # When false, only generates split rule files (.claude/rules/, .cursor/rules/, etc.)
265
+ # Whether to generate root-level context files (CLAUDE.md, AGENTS.md, .cursorrules,
266
+ # copilot-instructions.md). When false, only split rule files and .ai-context.json are written.
267
267
  attr_accessor :generate_root_files
268
268
 
269
269
  # Whether to embed the Anti-Hallucination Protocol section in generated context files.
@@ -272,14 +272,14 @@ module RailsAiContext
272
272
  attr_accessor :anti_hallucination_rules
273
273
 
274
274
  # File size limits (bytes) - increase for larger projects
275
- attr_accessor :max_file_size # Per-file read limit for tools (default: 2MB)
276
- attr_accessor :max_test_file_size # Test file read limit (default: 500KB)
275
+ attr_accessor :max_file_size # Per-file read limit for tools (default: 5MB)
276
+ attr_accessor :max_test_file_size # Test file read limit (default: 1MB)
277
277
  attr_accessor :max_schema_file_size # schema.rb / structure.sql parse limit (default: 10MB)
278
278
  # Doctor thresholds, not read caps: the view tools do not stop at them.
279
279
  attr_accessor :max_view_total_size # Size of app/views doctor warns past (default: 10MB)
280
280
  attr_accessor :max_view_file_size # Accepted and stored; no check reads it (default: 1MB)
281
281
  attr_accessor :max_search_results # Max lines a search may emit, matches and context together (default: 200)
282
- attr_accessor :max_validate_files # Max files per validate call (default: 20)
282
+ attr_accessor :max_validate_files # Max files per validate call (default: 50)
283
283
 
284
284
  # Additional MCP tool classes to register alongside built-in tools
285
285
  attr_accessor :custom_tools
@@ -14,9 +14,14 @@ module RailsAiContext
14
14
  source.to_s.include?("<%")
15
15
  end
16
16
 
17
- # The tag bodies, joined. Order is kept; line numbers are not.
17
+ # The code tag bodies, joined. Order is kept; line numbers are not.
18
18
  def tag_bodies(source)
19
- source.to_s.scan(TAG).flatten.join("\n")
19
+ source.to_s.scan(TAG).flatten.reject { |body| comment?(body) }.join("\n")
20
+ end
21
+
22
+ # Only `<%#` (or `<%-#`) is an ERB comment; `<% # note` is code whose first line is a Ruby comment.
23
+ def comment?(body)
24
+ body.to_s.start_with?("#")
20
25
  end
21
26
 
22
27
  # The same Ruby with everything outside the tags blanked rather than
@@ -30,7 +35,7 @@ module RailsAiContext
30
35
  match = Regexp.last_match
31
36
  out << blank(text[last...match.begin(0)])
32
37
  body = match[1].to_s
33
- out << (body.lstrip.start_with?("#") ? blank(body) : body)
38
+ out << (comment?(body) ? blank(body) : blank_comment_lines(body))
34
39
  last = match.end(0)
35
40
  end
36
41
  out << blank(text[last..].to_s)
@@ -47,5 +52,19 @@ module RailsAiContext
47
52
  text.gsub(/[^\n]/, " ")
48
53
  end
49
54
  private_class_method :blank
55
+
56
+ # Tags sharing a line are joined, so a comment left in would swallow the next tag's code.
57
+ def blank_comment_lines(body)
58
+ return body unless body.include?("#")
59
+
60
+ out = body.b
61
+ # Last first: a multibyte comment blanks to fewer bytes and would shift later offsets.
62
+ AstCache.parse_string(body).comments.reverse_each do |comment|
63
+ loc = comment.location
64
+ out[loc.start_offset...loc.end_offset] = blank(loc.slice).b
65
+ end
66
+ out.force_encoding(body.encoding)
67
+ end
68
+ private_class_method :blank_comment_lines
50
69
  end
51
70
  end
@@ -52,6 +52,7 @@ module RailsAiContext
52
52
  warnings = []
53
53
  config.introspectors.each do |name|
54
54
  data = context[name]
55
+ portable_errors!(data)
55
56
  if data.is_a?(Hash) && data[:error]
56
57
  warnings << { introspector: name.to_s, error: data[:error] }
57
58
  end
@@ -133,12 +134,11 @@ module RailsAiContext
133
134
  # nothing about the database, and answering for it would be the
134
135
  # fabrication this method exists to remove.
135
136
  return if schema[:adapter].nil?
136
- return unless SchemaAdapter.placeholder?(schema[:adapter])
137
137
 
138
138
  # "unknown" is a placeholder too. Swapping one for another buys nothing
139
139
  # and would bury the parse mode that at least says how the schema was read.
140
140
  resolved = SchemaAdapter.label(context)
141
- return if SchemaAdapter.placeholder?(resolved)
141
+ return if SchemaAdapter.placeholder?(resolved) || resolved == schema[:adapter]
142
142
 
143
143
  schema[:adapter_source] = schema[:adapter]
144
144
  schema[:adapter] = resolved
@@ -178,6 +178,17 @@ module RailsAiContext
178
178
  end
179
179
  end
180
180
 
181
+ # An exception message names files by absolute path, and the context is committed.
182
+ def portable_errors!(data)
183
+ case data
184
+ when Hash
185
+ data[:error] = PortablePath.relativize_text(data[:error], app.root) if data[:error].is_a?(String) && !data.frozen?
186
+ data.each_value { |value| portable_errors!(value) }
187
+ when Array
188
+ data.each { |value| portable_errors!(value) }
189
+ end
190
+ end
191
+
181
192
  def unavailable_reason
182
193
  Introspectors::StaticTier.unavailable_reason
183
194
  end