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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +122 -0
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +3 -1
- data/docs/CONFIGURATION.md +2 -2
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +1 -1
- data/exe/rails-ai-context +3 -4
- data/lib/rails_ai_context/action_filters.rb +69 -6
- data/lib/rails_ai_context/boot_manager.rb +21 -5
- data/lib/rails_ai_context/cli/entry_boot.rb +3 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +22 -3
- data/lib/rails_ai_context/concern_paths.rb +2 -1
- data/lib/rails_ai_context/configuration.rb +6 -6
- data/lib/rails_ai_context/erb_source.rb +22 -3
- data/lib/rails_ai_context/introspector.rb +13 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +43 -16
- data/lib/rails_ai_context/introspectors/eager_load.rb +22 -10
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +4 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +24 -10
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +12 -0
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +9 -1
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +13 -6
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +81 -25
- data/lib/rails_ai_context/introspectors/listeners/with_options_scope.rb +49 -12
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +15 -0
- data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +14 -3
- data/lib/rails_ai_context/introspectors/route_introspector.rb +62 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +8 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +7 -4
- data/lib/rails_ai_context/introspectors/schema_reader.rb +2 -0
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +5 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +18 -16
- data/lib/rails_ai_context/migration_status.rb +6 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -2
- data/lib/rails_ai_context/portable_path.rb +14 -0
- data/lib/rails_ai_context/schema_adapter.rb +6 -1
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
- data/lib/rails_ai_context/tools/base_tool.rb +9 -8
- data/lib/rails_ai_context/tools/diagnose.rb +4 -0
- data/lib/rails_ai_context/tools/generate_test.rb +19 -0
- data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
- data/lib/rails_ai_context/tools/get_partial_interface.rb +18 -4
- data/lib/rails_ai_context/tools/get_routes.rb +19 -9
- data/lib/rails_ai_context/tools/get_schema.rb +22 -4
- data/lib/rails_ai_context/tools/get_service_pattern.rb +44 -25
- data/lib/rails_ai_context/tools/get_test_info.rb +8 -0
- data/lib/rails_ai_context/tools/query.rb +3 -5
- data/lib/rails_ai_context/tools/runtime_info.rb +5 -3
- data/lib/rails_ai_context/tools/validate_semantics.rb +49 -13
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 49493277fcbe8b2da2d183de723ebf3d5f4c85101f5eff5b6a0c89a1ae4340a9
|
|
4
|
+
data.tar.gz: ebd28661e4d369b526027b8705c599372383796003287160a460ec3e99990632
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
data/docs/COMPATIBILITY.md
CHANGED
|
@@ -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
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
|
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 |
|
|
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
|
|
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` |
|
|
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,
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
266
|
-
# When false, only
|
|
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:
|
|
276
|
-
attr_accessor :max_test_file_size # Test file read limit (default:
|
|
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:
|
|
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 << (
|
|
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
|