rails-ai-context 5.39.0 → 5.39.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 +120 -0
- data/CONTRIBUTING.md +1 -0
- data/docs/ARCHITECTURE.md +2 -2
- data/docs/CLI.md +4 -1
- data/docs/GUIDE.md +3 -3
- data/docs/INTROSPECTORS.md +10 -4
- data/docs/SETUP.md +2 -1
- data/docs/TOOLS.md +3 -3
- data/docs/TROUBLESHOOTING.md +2 -2
- data/lib/generators/rails_ai_context/install/install_generator.rb +22 -9
- data/lib/rails_ai_context/brackets.rb +58 -6
- data/lib/rails_ai_context/cli/app_root.rb +12 -3
- data/lib/rails_ai_context/database_yml.rb +12 -1
- data/lib/rails_ai_context/doctor.rb +24 -1
- data/lib/rails_ai_context/erb_source.rb +41 -26
- data/lib/rails_ai_context/gem_lock.rb +4 -1
- data/lib/rails_ai_context/install/initializer_file.rb +111 -12
- data/lib/rails_ai_context/introspectors/action_resolver.rb +14 -18
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +3 -4
- data/lib/rails_ai_context/introspectors/declared_constant.rb +8 -0
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +30 -6
- data/lib/rails_ai_context/introspectors/env_references.rb +16 -0
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -0
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +1 -0
- data/lib/rails_ai_context/introspectors/job_introspector.rb +37 -9
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +15 -6
- data/lib/rails_ai_context/introspectors/model_introspector.rb +11 -2
- data/lib/rails_ai_context/introspectors/module_aliases.rb +1 -0
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +6 -2
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +22 -4
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +41 -2
- data/lib/rails_ai_context/introspectors/recurring_schedules.rb +18 -2
- data/lib/rails_ai_context/introspectors/render_calls.rb +40 -2
- data/lib/rails_ai_context/introspectors/route_introspector.rb +6 -3
- data/lib/rails_ai_context/introspectors/side_effects.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +2 -1
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +57 -28
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +24 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +108 -20
- data/lib/rails_ai_context/introspectors/view_introspector.rb +50 -25
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +98 -22
- data/lib/rails_ai_context/ruby_source.rb +102 -0
- data/lib/rails_ai_context/schema_version.rb +22 -2
- data/lib/rails_ai_context/tools/analyze_feature.rb +15 -8
- data/lib/rails_ai_context/tools/diagnose.rb +6 -1
- data/lib/rails_ai_context/tools/generate_test.rb +27 -14
- data/lib/rails_ai_context/tools/get_concern.rb +48 -36
- data/lib/rails_ai_context/tools/get_context.rb +44 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +36 -31
- data/lib/rails_ai_context/tools/get_conventions.rb +57 -42
- data/lib/rails_ai_context/tools/get_edit_context.rb +23 -7
- data/lib/rails_ai_context/tools/get_env.rb +58 -13
- data/lib/rails_ai_context/tools/get_helper_methods.rb +9 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +14 -14
- data/lib/rails_ai_context/tools/get_model_details.rb +4 -6
- data/lib/rails_ai_context/tools/get_partial_interface.rb +115 -62
- data/lib/rails_ai_context/tools/get_service_pattern.rb +21 -11
- data/lib/rails_ai_context/tools/get_stimulus.rb +2 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +6 -3
- data/lib/rails_ai_context/tools/get_view.rb +28 -10
- data/lib/rails_ai_context/tools/migration_advisor.rb +7 -3
- data/lib/rails_ai_context/tools/review_changes.rb +19 -8
- data/lib/rails_ai_context/tools/search_code.rb +23 -7
- data/lib/rails_ai_context/tools/validate_semantics.rb +53 -45
- data/lib/rails_ai_context/unbooted_settings.rb +9 -4
- data/lib/rails_ai_context/version.rb +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: 9009c01e492d1f80697f24c8a405105a672e9658600ea91a2900e927ae7e393c
|
|
4
|
+
data.tar.gz: 7b1c562d16a0eb4dcbb2e2709ef3aa584770c49cb29e463ce643d7aa3b822aa9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d0a8ab86108420d68e1222eea52a90bce730a8332cb798fe7fd934bf5b30185a309ac9543bada3889c6c96b2d28766edfe4546de6ca23dc6f99ac10385cc973
|
|
7
|
+
data.tar.gz: c5e4bd9ba02f4477a92454fac2b44f7043f8f2a2e9dd05ffe9aa0e5985590310a198c7b36fd7af33f21d3da0c2c2335e4b4c778638fe185e63783bbb60d0b61f
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,126 @@ 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.39.1] - 2026-10-31
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **A comment, a string or a heredoc is no longer read as code.** About 150
|
|
13
|
+
answers came from a regex that matched text Ruby never runs: a
|
|
14
|
+
commented-out line, a word inside a string, a line inside a heredoc or an
|
|
15
|
+
`=begin` block. Each of those readers now reads only the code, through
|
|
16
|
+
Prism, the existing listeners, or the new `RubySource`, which blanks
|
|
17
|
+
comments and string bodies while keeping every line and column. The
|
|
18
|
+
sections below list what each surface stops reporting and what it now
|
|
19
|
+
finds.
|
|
20
|
+
- **ERB.** A `<%%` escape is text, as Erubi prints it. A Ruby comment inside a
|
|
21
|
+
code tag (`<% # render "old" %>`) counts for nothing, and text inside
|
|
22
|
+
`<!-- -->` is no markup, while ERB inside it still runs.
|
|
23
|
+
- **Views and partials** (`rails_get_view`, `rails_get_partial_interface`).
|
|
24
|
+
A render inside a string, a comment or plain page text is no render, and a
|
|
25
|
+
`partial:` key inside another helper's hash (`data: { partial: }`) is no
|
|
26
|
+
partial. A form builder named in a string no longer renames a record
|
|
27
|
+
render. A partial's locals leave out its own variables and what a
|
|
28
|
+
commented-out loop names, and read `local_assigns` and `defined?` only from
|
|
29
|
+
Ruby. `formats:` and `partial:` inside a string value, a jbuilder line's
|
|
30
|
+
trailing comment and a Turbo Stream builder named in a comment no longer
|
|
31
|
+
move a render site. `rails_get_view` prints the form builders (with
|
|
32
|
+
counts) and components the views it lists use, which it never printed
|
|
33
|
+
before.
|
|
34
|
+
`rails_get_helper_methods` lists every module an `include` names
|
|
35
|
+
(`include A, B`, `include Rails.application.routes.url_helpers`) and none a
|
|
36
|
+
heredoc holds.
|
|
37
|
+
- **Turbo** (`rails_get_turbo_map`). A frame, stream action or Drive
|
|
38
|
+
attribute inside an ERB comment, or a Ruby comment in a code tag, is not
|
|
39
|
+
counted. A frame's `src:` and a
|
|
40
|
+
subscription's parts on the next line of a wrapped call are read, so the
|
|
41
|
+
stream wiring stops warning about a mismatch that is not there. A
|
|
42
|
+
permanent element's id comes from its `id` attribute, never `data-id`, and
|
|
43
|
+
elements whose attributes hold ERB, or that a tag helper writes, are found.
|
|
44
|
+
`format.turbo_stream` in a comment is no stream response.
|
|
45
|
+
- **Stimulus** (`rails_get_stimulus`). A commented import, registration,
|
|
46
|
+
`extends`, `static targets`, `this.x()` call or Turbo event listener counts
|
|
47
|
+
for nothing. An arrow-function class field is an action, a wrapped import
|
|
48
|
+
is read, a class written on one line has its methods, and lifecycle lists
|
|
49
|
+
only the hooks the class defines (`this.observer.disconnect()` is no
|
|
50
|
+
hook). A commented alias entry and a commented plugin import in
|
|
51
|
+
`vite.config` are skipped.
|
|
52
|
+
- **Controllers and actions** (`rails_get_context`, `rails_get_controllers`,
|
|
53
|
+
`rails_get_edit_context`, `rails_search_code`). Only a real assignment sets
|
|
54
|
+
an ivar: `@post == x` and `@post.title = x` do not. `render action: :show`
|
|
55
|
+
and `render "edit"` are read, so are methods called under a modifier `if`
|
|
56
|
+
and filters written as `private def`. A file that declares another class
|
|
57
|
+
first no longer lends that class's method. The class a method belongs to
|
|
58
|
+
respects nesting and `class << self`, so `class Error < StandardError; end`
|
|
59
|
+
on the line above no longer names it. An action's Side Effects lines are
|
|
60
|
+
redacted as its source listing is, so a key passed to the receiver reads
|
|
61
|
+
`[FILTERED]`.
|
|
62
|
+
- **Validate** (`rails_validate`, rails level). A route or helper inside a
|
|
63
|
+
heredoc or `=begin` block no longer silences a missing-route warning, and
|
|
64
|
+
every line of a multi-line `helper_method` counts. A controller set through
|
|
65
|
+
`data: { controller: }` is checked, and one in an HTML comment is not. An
|
|
66
|
+
email address in a string is no ivar, and a comparison is no assignment. A
|
|
67
|
+
commented broadcast or subscription is not checked. The memory warning
|
|
68
|
+
reads the call: a scope or association chain (`Post.active.map`,
|
|
69
|
+
`current_user.posts.each`) warns, and a chain over `params`, a string, a
|
|
70
|
+
literal, or a call that takes arguments (`JSON.parse(body).map`,
|
|
71
|
+
`Dir.glob(x).map`) does not.
|
|
72
|
+
- **analyze_feature.** Tag-helper `data: { controller: }` is read, ERB inside
|
|
73
|
+
`data-controller` gives no names and an HTML-commented one gives none, the
|
|
74
|
+
test count reads `it(...)`, `specify` and `it { }` one-liners and skips
|
|
75
|
+
heredoc text, and every `ENV.fetch` form is listed.
|
|
76
|
+
- **Conventions and test info** (`rails_get_config(section:"conventions")`,
|
|
77
|
+
`rails_get_test_info`). The create action ends at its own `end` inside a
|
|
78
|
+
namespace module, and commented code adds no permission check, rescue,
|
|
79
|
+
read-only page, default locale or UI language. Japanese flash text with
|
|
80
|
+
kanji is Japanese. Devise test helpers and route helpers count only where
|
|
81
|
+
the code uses them.
|
|
82
|
+
- **Models, concerns and performance.** A custom validate method's body is
|
|
83
|
+
read when the def has parameters or a trailing comment. A heredoc adds no
|
|
84
|
+
structure line, makes no model abstract, and holds no concern macro. A
|
|
85
|
+
multi-line macro and an `included { }` block are read whole. A class whose
|
|
86
|
+
superclass sits on the next line is found. A comment naming
|
|
87
|
+
`Rails::Engine` makes no Rack app an engine. Strict locals read every
|
|
88
|
+
default, as Rails' own pattern does, a word in a scope's SQL string calls
|
|
89
|
+
no scope, a comparison or an `order` on a counter column is not a write,
|
|
90
|
+
and `Model.all` in a comment is not flagged.
|
|
91
|
+
- **Jobs and services** (`rails_get_job_pattern`,
|
|
92
|
+
`rails_get_service_pattern`). Commented `queue_as`, broadcasts, mailers,
|
|
93
|
+
`include` words and `.success` calls count for nothing, and heredoc text
|
|
94
|
+
adds no guard clause. `queue_as proc { }.call` is a computed queue, not a
|
|
95
|
+
Proc. A mailer whose parent is the app's own mailer base is listed. A
|
|
96
|
+
whenever runner names the job it enqueues or performs
|
|
97
|
+
(`ReportJob.new.perform`), or no class so its command shows. A rescue modifier rescues StandardError, and a service named only
|
|
98
|
+
in a comment has no caller there.
|
|
99
|
+
- **Config and data files.** The credentials example is read as YAML, so a
|
|
100
|
+
block scalar adds no key and nested keys keep their full path; one that is
|
|
101
|
+
not YAML is read line by line, by indentation. A
|
|
102
|
+
`=begin` block sets no default locale. `::ENV[...]`, `::ENV.fetch` and
|
|
103
|
+
`ENV["X"] ||=` count as ENV reads. Shards come from the database list,
|
|
104
|
+
in the guide's layout too, and `shard_count:` is no shard. A computed puma
|
|
105
|
+
setting reads as unknown, while `ENV[...] || 2` keeps its default. A
|
|
106
|
+
commented health route is no health check, an `ENV.fetch` default names the
|
|
107
|
+
adapter, doctor finds a password in a flow-style mapping, the schema
|
|
108
|
+
version comes from the `define` call, mise's `[tools.ruby]` table is read,
|
|
109
|
+
and a `CREATE TABLE` or `CREATE VIEW` inside a PostgreSQL function body or
|
|
110
|
+
string is not a table or view.
|
|
111
|
+
- **generate_test, diagnose, migration_advisor, review_changes.** A
|
|
112
|
+
paren-less `create :post` counts, a `def` inside a heredoc names no sign-in
|
|
113
|
+
helper, a string in an inclusion list is a value, and a `%r{}` format is
|
|
114
|
+
read like `/.../`. `diagnose` takes no receiver from a string.
|
|
115
|
+
`:null => false` and `{ null: false }` get the NOT NULL warning. A
|
|
116
|
+
validation moved into a
|
|
117
|
+
heredoc or wrapped in `=begin` is reported as removed.
|
|
118
|
+
- **Install and boot.** `::RailsAiContext.configure` is the gem's call, a
|
|
119
|
+
heredoc line is not, and a guard that is only a comment or closes before
|
|
120
|
+
the call does not count, in doctor and in the install alike: the install
|
|
121
|
+
wraps a call no guard covers, and upgrades a bare guard with a note after
|
|
122
|
+
it. The generator leaves heredoc text alone and keeps every magic comment
|
|
123
|
+
above the guard it adds. The static tier reads heredoc escapes and `%W`
|
|
124
|
+
and `%I` lists. The CLI finds the app a Rails 5.1 engine's `bin/rails`
|
|
125
|
+
names through `__FILE__`, and a Rails 4.2 or 5.0 engine's `test/dummy` or
|
|
126
|
+
`spec/dummy`.
|
|
127
|
+
|
|
8
128
|
## [5.39.0] - 2026-10-31
|
|
9
129
|
|
|
10
130
|
### Changed
|
data/CONTRIBUTING.md
CHANGED
|
@@ -72,6 +72,7 @@ The `ToolRunner` (`lib/rails_ai_context/cli/tool_runner.rb`) handles CLI executi
|
|
|
72
72
|
- Target Ruby 3.1 (the gem's floor). Ruby 3.2+ features (`Data.define`, `Regexp` `timeout:`, etc.) need a runtime-guarded fallback so 3.1 keeps working
|
|
73
73
|
- Support Rails 7.0 through 8.1. Guard Rails 7.1+ APIs with `respond_to?`/`defined?` so introspectors degrade cleanly on 7.0
|
|
74
74
|
- Every introspector returns a Hash. Let a failure raise: the loop in `Introspector#call` records it as `{ error: msg }` and warns, so no introspector wraps its own errors
|
|
75
|
+
- Read Ruby source with Prism or a listener. A regex kept over Ruby runs on `RubySource.code_only` or `RubySource.without_comments` text, so a comment, a string or a heredoc cannot match (the AST-vs-regex rule in `docs/INTROSPECTORS.md`)
|
|
75
76
|
- MCP tools return `MCP::Tool::Response` objects
|
|
76
77
|
- All tools must be prefixed with `rails_` and annotated as read-only
|
|
77
78
|
|
data/docs/ARCHITECTURE.md
CHANGED
|
@@ -137,7 +137,7 @@ Four more answer for the app's databases and for what `rails_query` runs on them
|
|
|
137
137
|
|
|
138
138
|
### AST Engine
|
|
139
139
|
|
|
140
|
-
**Prism
|
|
140
|
+
Ruby source is read with **Prism**. A regex still reads Ruby only in a case the [AST-vs-regex rule](INTROSPECTORS.md#choosing-between-ast-and-regex) allows, such as a prefilter the parse then checks, or over the text `RubySource` gives it: comments, `=begin` blocks and string bodies blanked to spaces with every line and offset kept, so a comment, a string or a heredoc cannot match. The readers that also run before the app boots (the install's read of the initializer and its guard, and the settings read without running the app) parse with the standard library's Ripper, since an app's bundle may pin Prism.
|
|
141
141
|
|
|
142
142
|
- **AstCache** - Thread-safe parse cache (`Concurrent::Map`), keyed by the SHA256 of the file's content; a stat match answers without a read for a file already two seconds older than the read that recorded it
|
|
143
143
|
- **SourceIntrospector** - Single-pass Prism Dispatcher walks the AST once, feeding every registered listener simultaneously
|
|
@@ -225,7 +225,7 @@ Result: controller and view tools automatically include relevant schema informat
|
|
|
225
225
|
|
|
226
226
|
Thor-based CLI that works standalone (no Gemfile entry):
|
|
227
227
|
|
|
228
|
-
- `AppRoot` - Decides which app a command reads before anything reads the working directory: `--app-path` as given, else the working directory, else the nearest app root above it, else the one app below it. Several below are refused by name, except by the plugin's serve, which `AppRoot.target` hands the folder of apps to route (see MCP Server), and `init`, which sets them all up
|
|
228
|
+
- `AppRoot` - Decides which app a command reads before anything reads the working directory: `--app-path` as given, else the working directory, else the nearest app root above it, else the one app below it. A root that has a `bin/rails` and no app source reads the app that `bin/rails` names below it, else its `test/dummy` or `spec/dummy` app. Several below are refused by name, except by the plugin's serve, which `AppRoot.target` hands the folder of apps to route (see MCP Server), and `init`, which sets them all up
|
|
229
229
|
- `EntryBoot` - Boots the app `AppRoot` chose for the standalone binary, or says which tier it fell back to
|
|
230
230
|
- `Refusal` - Where the plugin's serve cannot serve, answers the MCP handshake with no tools and the reason, so Claude Code does not skip the plugin; also the line parse and the `initialize` answer the router and the proxy share
|
|
231
231
|
- `ToolRunner` - Parses CLI args, resolves tool names, executes tools, formats output
|
data/docs/CLI.md
CHANGED
|
@@ -77,7 +77,10 @@ directory if it is an app, else the nearest app root above it (a directory with
|
|
|
77
77
|
`config/application.rb`, `config/environment.rb`, or a `bin/rails` that boots an
|
|
78
78
|
app or an engine; never `$HOME`, `node_modules` or a gem install), else the one
|
|
79
79
|
app a level or two below it - looked for everywhere but `/`, `$HOME` and the
|
|
80
|
-
directories above it, which hold too much to search. A
|
|
80
|
+
directories above it, which hold too much to search. A root found by its
|
|
81
|
+
`bin/rails` alone, with no app source, reads the app below it that `bin/rails`
|
|
82
|
+
names as `APP_PATH` (a monorepo's root), else its `test/dummy` or `spec/dummy`
|
|
83
|
+
app (an engine whose code is all in `lib/`). A walk names the app it chose on stderr, as
|
|
81
84
|
`[rails-ai-context] using app at a/`, so `serve` keeps stdout clean. A folder
|
|
82
85
|
holding several apps is refused with one `--app-path` command per app, and
|
|
83
86
|
`tool --list` there lists the tools without an app. The chosen root is the
|
data/docs/GUIDE.md
CHANGED
|
@@ -116,7 +116,7 @@ No Gemfile entry, no initializer, no files in your project besides config and co
|
|
|
116
116
|
|
|
117
117
|
Choices 1 and 3 set `tool_mode` to `:mcp`, choice 2 sets it to `:cli`. Choice 3 also sets `context_files` to `false`. Any other answer takes the default.
|
|
118
118
|
4. **Writes the MCP config for each selected tool** (`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json` and `.github/mcp.json`, `opencode.json`, `.codex/config.toml`). Skipped for choice 2, which instead lists any config that still starts `rails-ai-context` and asks `Remove rails-ai-context from them? (y/N)`: CLI mode starts no MCP server. A yes takes out only the gem's entries, as step 2 does. With choice 3 it lists instead the context files an earlier setup generated, which nothing refreshes once the install writes no context files, and asks `Remove the generated parts? Lines you wrote stay. (y/N)`; a yes removes them the way step 2 does, and `.ai-context.json` with them. An existing file is merged: only the `rails-ai-context` entry is added or updated, and keys in it the gem never writes stay. An entry under that name whose command the gem did not write (one that starts the server through Docker) is kept as it is, with a note. One that does not parse as JSON, or holds comments a rewrite would drop, is left as it is, with a warning that names the entry to add by hand. Each file is read as its client reads it. VS Code's and OpenCode's take JSONC, comments and trailing commas (read under json 2.9 or later); Claude Code reads `.mcp.json` as plain JSON, and Cursor's `.cursor/mcp.json` is held to plain JSON too, so a comment or a trailing comma in either is reported as one the client cannot read, and the file left as it is. In `.codex/config.toml` the server's table is found however its name is quoted, and replaced where it stands; an entry of the gem's written as an inline table or with dotted keys is left as it is, with a warning, since a table added beside it would declare the server twice, which Codex refuses.
|
|
119
|
-
5. **Creates or updates `config/initializers/rails_ai_context.rb`.** A new file gets your three answers as live lines (`config.ai_tools`, `config.tool_mode`, `config.context_files`) and every other option commented out at its default, wrapped in `if defined?(RailsAiContext) && RailsAiContext.respond_to?(:configure)`; it passes the omakase Rubocop a new Rails app runs. In an existing file it rewrites those three lines where the value changed - keeping the line's indentation and any note of your own after it - appends any config section the file lacks, adds the guard if it is missing, and leaves the rest alone.
|
|
119
|
+
5. **Creates or updates `config/initializers/rails_ai_context.rb`.** A new file gets your three answers as live lines (`config.ai_tools`, `config.tool_mode`, `config.context_files`) and every other option commented out at its default, wrapped in `if defined?(RailsAiContext) && RailsAiContext.respond_to?(:configure)`; it passes the omakase Rubocop a new Rails app runs. In an existing file it rewrites those three lines where the value changed - keeping the line's indentation and any note of your own after it - appends any config section the file lacks, adds the guard if it is missing (below the comments the file opens with, so a magic comment still applies), upgrades a bare `if defined?(RailsAiContext)` guard, and leaves the rest alone.
|
|
120
120
|
6. **Writes `.rails-ai-context.yml`** with `ai_tools`, `tool_mode` and `context_files`. Only a key whose value changed is rewritten, where it stands; your comments and other keys are kept, and a run that changes no value leaves the file as it was.
|
|
121
121
|
7. **Adds a line to `.gitignore`**, if the file exists: `.ai-context.json` (not for choice 3). A line that is already there is not added again. `.codex/config.toml` is no longer added: it holds nothing machine-specific and is committed like the other MCP configs, so the line an earlier release added is taken out.
|
|
122
122
|
8. **Asks about a pre-commit hook:** `Install a pre-commit hook that checks staged Ruby, ERB and JavaScript files for syntax errors? (y/N)`. Only `y` installs it. The hook runs `bundle exec rails-ai-context tool validate` (`rails-ai-context tool validate` in a standalone install) on the staged `.rb`, `.erb`, `.js`, `.mjs` and `.cjs` files and stops a commit whose files do not parse (JavaScript is parsed by `node`, as [`rails_validate`](TOOLS.md#rails_validate) describes); the Rails-reference checks (`level=rails`) are left to the tool, as Brakeman would slow every commit. An app that cannot boot (an initializer that needs a variable the shell lacks, say) is still checked, from its source, and the hook shows why it did not boot; it shows whatever else the check prints to stderr too, so a check that cannot run says why. Every staged name is read whole, one with characters outside ASCII included; a name holding a comma, which the files list cannot carry, is named and left unchecked. It goes where git runs hooks (`git rev-parse --git-path hooks`), so an app checked out as a submodule, an app inside a monorepo and a repo with `core.hooksPath` set all get it where git will run it. The hook names the apps it covers and validates each one's staged files (deleted ones aside) from inside that app; installing in a second app of the same monorepo adds it to the hook, unless the hook was changed by hand, which it says, and for an app below the top of the repository the question names the repository. A hook an earlier version wrote, and nobody changed since, is brought up to date without asking again: the oldest handed validate the files a commit deletes too, and the commit failed on them, and later ones ran `rails 'ai:tool[validate]'`, which has to boot the app, with its errors thrown away, so an app that could not boot failed every commit with no reason given. Any other hook that runs `rails-ai-context` - one changed by hand, or a team's own that calls it among other checks - is left as it is, with a message. No hook is offered for an app the repository does not track yet (an app under a dotfiles repository at `$HOME`, say), nor into a `core.hooksPath` outside the repository, which every repository using it shares; both say why. No question is asked outside a git repository or when the hook already covers the app. When another `pre-commit` hook exists there it prints `Skipped pre-commit hook (existing hook found - add manually)` and moves on.
|
|
@@ -679,7 +679,7 @@ rails_get_stimulus(detail: "full")
|
|
|
679
679
|
|
|
680
680
|
### rails_get_view
|
|
681
681
|
|
|
682
|
-
Returns view template contents, partials, and Stimulus controller references. In standard detail, includes **Schema Hints** for models inferred from instance variables.
|
|
682
|
+
Returns view template contents, partials, and Stimulus controller references. In standard detail, includes **Schema Hints** for models inferred from instance variables, and opens with the form builders the views call (with counts) and the ViewComponents they render.
|
|
683
683
|
|
|
684
684
|
**Parameters:**
|
|
685
685
|
|
|
@@ -1710,7 +1710,7 @@ Runs the checks below and reports an AI readiness score (0-100). A check that do
|
|
|
1710
1710
|
| Tests | A test suite is found: the app's, or in an engine's `test/dummy` the engine's |
|
|
1711
1711
|
| Migrations | Migration files exist, counted over every database the app migrates, and in an engine's `test/dummy` the engine's own |
|
|
1712
1712
|
| Context files | Generated context files exist where `config.output_dir` puts them, and a context run would leave them as they are. A file a run would rewrite, because an older version of the gem wrote it or the app changed under it, is named; a file that is only older than the code is not. In an engine's dummy app the install at the engine's root is read, and its own context command runs there, into a copy. No row on an MCP-only install |
|
|
1713
|
-
| Initializer guard | Shown only when `config/initializers/rails_ai_context.rb`
|
|
1713
|
+
| Initializer guard | Shown only when `config/initializers/rails_ai_context.rb` makes a `RailsAiContext.configure` call no guard covers (a guard in a comment, or an `if` that ends before the call, covers nothing), or guards on `defined?(RailsAiContext)` alone |
|
|
1714
1714
|
| MCP configs | Each selected tool's MCP config file exists, parses, and holds a rails-ai-context server whose command can start: `bundle exec` only where the app's lockfile carries the gem and the bundle's copy has its executable, the `rails-ai-context` binary only where it is on PATH, and a warning when a config runs that binary in an app whose bundle carries the gem. A Codex entry that still starts with the Ruby environment an earlier release saved warns, and fails when that saved `PATH` no longer reaches its command (fix: run the install). The command is looked up, never run. Skipped in CLI-only mode |
|
|
1715
1715
|
| MCP server | MCP server can be built, with an `mcp` gem this gem supports (warns, as the boot does, when the app's bundle pins one outside that range) |
|
|
1716
1716
|
| Introspector health | Every configured introspector returns data |
|
data/docs/INTROSPECTORS.md
CHANGED
|
@@ -259,17 +259,21 @@ Regex is the right tool, and stays, for:
|
|
|
259
259
|
- **A prefilter before the parse.** Parsing every `.rb` under `app/` to find the handful that declare something costs more than a line match that ends the read; the AST still decides, on the files the match keeps.
|
|
260
260
|
- **Anything the listeners cannot scope.** Tying a call to the enclosing action or `namespace` block needs block scope the listeners do not track, so those fall back to line scanning.
|
|
261
261
|
|
|
262
|
+
A regex kept over Ruby source runs on the text `RubySource` gives it, so a comment or a string cannot match. `RubySource.without_comments` blanks comments, `=begin` blocks and the `__END__` section; `RubySource.code_only` also blanks string, heredoc, backtick and regex bodies. Both keep every line and character offset, so a match still points at the file. To capture a string a call takes (`render "posts/form"`), match the `without_comments` text and keep a match only where the same offset in the `code_only` text is not blank. A template's Ruby comes from `ErbSource` (`tag_bodies`, or `masked` to keep the template's offsets).
|
|
263
|
+
|
|
262
264
|
Every remaining regex over `.rb` content carries a one-line comment saying which of these it is. If you add one without a reason, convert it instead.
|
|
263
265
|
|
|
264
266
|
### Readers built on the listeners
|
|
265
267
|
|
|
266
268
|
Some questions take more than one walk to answer, and the answer has to be the
|
|
267
269
|
same wherever it is asked. Those live as their own modules under
|
|
268
|
-
`Introspectors/`, and a tool calls one rather than repeating the walk
|
|
270
|
+
`Introspectors/`, and a tool calls one rather than repeating the walk. The
|
|
271
|
+
last two rows, `RubySource` and `ErbSource`, are the text readers in
|
|
272
|
+
`lib/rails_ai_context/` that a regex over Ruby or ERB reads through:
|
|
269
273
|
|
|
270
274
|
| Module | What it answers |
|
|
271
275
|
|:-------|:---------------|
|
|
272
|
-
| `DeclaredConstant` | The constant a source file calls its own class, against the one its path camelizes to, and the static tier's one rule for naming a written class, module or constant: the full name each open or write gives (`declare`, `full_name`), Module.nesting inside a body or at a listener record (`body_nesting`, `record_nesting`),
|
|
276
|
+
| `DeclaredConstant` | The constant a source file calls its own class, against the one its path camelizes to, and the static tier's one rule for naming a written class, module or constant: the full name each open or write gives (`declare`, `full_name`), Module.nesting inside a body or at a listener record (`body_nesting`, `record_nesting`), the names Ruby tries for a reference (`candidate_names`), and the innermost class or module a line sits in, as written (`enclosing`; a `class << self` body is its class's), which `rails_search_code` and `rails_get_edit_context` name a line by |
|
|
273
277
|
| `ActionPresence` | Whether a controller has an action: the public methods and `define_method` names of the controller, its ancestors up to Rails' base and the modules they include, the templates at each ancestor's view prefix, and the ancestors or modules no app source holds, which leave a missing action unverified. `rails_validate` and `rails_generate_test` both ask it |
|
|
274
278
|
| `ControllerSettings` | The layout a controller renders in (declared on it or an ancestor, else the `layouts/<controller_path>` file Rails finds by name, walking up the chain) and the `allow_browser`, `protect_from_forgery`, `add_flash_types`, `default_form_builder` and `wrap_parameters` calls it and its ancestors make. `rails_get_controllers` and `rails_get_view` both ask it |
|
|
275
279
|
| `TableName` | The table a model reads, from its own declarations |
|
|
@@ -281,11 +285,11 @@ same wherever it is asked. Those live as their own modules under
|
|
|
281
285
|
| `RetryPolicy` | What a job does when it raises, as a reader would write it: the macro, its exceptions, then `attempts:` and `wait:` whatever order the source put them in |
|
|
282
286
|
| `SourceCalls` | Which other classes a file hands work to, off the call nodes: the verb list, the framework receivers left out, and the call or the class alone |
|
|
283
287
|
| `ServiceClasses` | Which classes under `app/services`, `app/interactions` and `app/interactors` are services and which are only the base of one, for the tool's listing and the generated files' line alike |
|
|
284
|
-
| `EnvReferences` | Every ENV name the app's source reads, file by file, for `rails_get_config`'s `env` section and the context file's `env` section alike: `app`, `config` and `lib` Ruby, ERB and config YAML, `config.ru`, `db/seeds` and the `bin/` scripts whose shebang is Ruby, with config YAML on `sensitive_patterns` read for the names in its ERB tags only |
|
|
288
|
+
| `EnvReferences` | Every ENV name the app's source reads, file by file, for `rails_get_config`'s `env` section and the context file's `env` section alike: `app`, `config` and `lib` Ruby, ERB and config YAML, `config.ru`, `db/seeds` and the `bin/` scripts whose shebang is Ruby, with config YAML on `sensitive_patterns` read for the names in its ERB tags only. `fetch_default` gives what an `ENV.fetch` falls back to (a second argument, or a block's one statement), which the puma settings and a `database.yml` adapter read as the value with the variable unset |
|
|
285
289
|
| `AnywayConfigs` | Each Anyway::Config class under `config/configs` and `app/configs`, every class in a file, with the attributes its own body declares (a nested class keeps its macros) and the env name anyway_config reads each from: `env_prefix`, else `config_name`, else the name before `Config`. `rails_get_config(section:"env")` lists them |
|
|
286
290
|
| `GemfileGems` | The one Gemfile read, off `GemfileDslListener`: its entries with options and groups (the gems section's local gems and groups) and the gem names (every other asker), so a commented-out `gem` line is no gem anywhere; a file named by `eval_gemfile` is followed when it is inside the bundle's directory (the app root, or the directory of the Gemfile `config/boot.rb` points at), and one it cannot read is an `:unknown_gems` entry. GemLock takes the declared Ruby and the gem names from it once the gem is loaded; the walk is kept until one of the files changes. Before the boot, GemLock walks the one Gemfile with `AstWalk` instead and leaves `eval_gemfile` unread |
|
|
287
291
|
| `ModuleAliases` | Which app file a bare JS import specifier names: tsconfig/jsconfig `compilerOptions.paths` followed through `extends` (relative files and installed packages), and a vite/webpack/rspack `resolve.alias` written as a literal object. The Stimulus scan uses it to tie a registration or a base class to the controller file it imports |
|
|
288
|
-
| `RenderCalls` | A template's render calls read as `RenderingHelper#render` reads them: each call's argument text, the partial it names, the folder a bare name is looked up in (the view's own, then `application/`), and the locals it hands over, a `collection:` or `object:` record among them. The view introspector, `rails_get_view` and `rails_get_partial_interface` all read them here |
|
|
292
|
+
| `RenderCalls` | A template's render calls read as `RenderingHelper#render` reads them: each call's argument text, the partial it names, the folder a bare name is looked up in (the view's own, then `application/`), and the locals it hands over, a `collection:` or `object:` record among them. In an ERB or Ruby template a call counts only where the template's Ruby makes it (`code_text`, through `ErbSource.masked` for ERB), so a render in a comment, a string or page text is none; HAML and Slim are read whole. The view introspector, `rails_get_view` and `rails_get_partial_interface` all read them here |
|
|
289
293
|
| `HelperNames` | The helper methods a view can call: every method the app's helper modules define in every code root, and those of a module they `include`, found through the app's autoload roots (`lib` among them) or in its enclosing namespace's file (`CanonicalURL::Helpers` in `canonical_url.rb`). `rails_get_partial_interface` uses it so a helper call is not read as a local |
|
|
290
294
|
| `Interaction` | Whether a class runs as an ActiveInteraction, following its superclass chain through the app's own sources, and the filters it takes - inherited ones first, one per name, each carrying the filters nested inside its block. See the **Interaction filter** entry in `GLOSSARY.md` |
|
|
291
295
|
| `RecurringSchedules` | The recurring tasks each scheduler reads from its own file: Solid Queue's `config/recurring.yml`, sidekiq-cron's `config/schedule.yml`, sidekiq-scheduler's section of each `config/sidekiq*.yml`, GoodJob's `config.good_job.cron` and whenever's `config/schedule.rb`. The job introspector hands it its own config walk, so a file it also reads for queue or mailer settings is walked once |
|
|
@@ -301,6 +305,8 @@ same wherever it is asked. Those live as their own modules under
|
|
|
301
305
|
| `SqliteVirtualTables` | SQLite's virtual tables and their shadow tables, read from `sqlite_master`. The schema and database stats introspectors ask it |
|
|
302
306
|
| `RenderedRecord` | The records a bare `render @post.comments` hands to Rails, and the partial Rails renders for them. `rails_get_partial_interface` and the view template introspector ask it |
|
|
303
307
|
| `NodeSource` | A node's source with the bodies of the heredocs it opens, which Prism's `slice` leaves out |
|
|
308
|
+
| `RubySource` | Ruby source with what Ruby never runs blanked to spaces, every line and character offset kept: `without_comments` blanks comments, `=begin` blocks and `__END__`, and `code_only` also blanks string, heredoc, backtick and regex bodies, keeping their delimiters and the code in `#{}`. It loads Prism, so code that runs before the app boots must not call it |
|
|
309
|
+
| `ErbSource` | The Ruby in a template's tags, read as Erubi reads them (`<%%` is text, `<%#` a comment): `tag_bodies` joins the code tags with their Ruby comments blanked, and `masked` keeps the whole template in place with its text, delimiters and comments blanked and each tag read as `code_only` reads it |
|
|
304
310
|
|
|
305
311
|
### Confidence tagging
|
|
306
312
|
|
data/docs/SETUP.md
CHANGED
|
@@ -134,7 +134,8 @@ which leaves the committed file as it is.
|
|
|
134
134
|
folder, or the one app below it, as every command does. At user scope the
|
|
135
135
|
plugin starts in every project; outside a Rails app its server connects with
|
|
136
136
|
no tools and tells the session why. A monorepo's root whose `bin/rails`
|
|
137
|
-
boots an app below it serves that app
|
|
137
|
+
boots an app below it serves that app, and an engine whose code is all in
|
|
138
|
+
`lib/` serves its `test/dummy` or `spec/dummy` app. On Windows the plugin serves one app
|
|
138
139
|
per session: in a folder of several apps its server connects with no tools
|
|
139
140
|
and names each app and the `--app-path` command that runs its tools.
|
|
140
141
|
Elsewhere a folder that holds several apps
|
data/docs/TOOLS.md
CHANGED
|
@@ -99,7 +99,7 @@ Redaction treats a value under a secret-named key as a secret unless it is clear
|
|
|
99
99
|
|
|
100
100
|
### `rails_get_edit_context`
|
|
101
101
|
|
|
102
|
-
Method-aware code extraction with surrounding class context. Every line returned passes through redaction, so in a file no pattern names a string literal under a secret-named key (8+ characters, unless it is clearly not a credential: a message, URL, path, placeholder, version, date, header, env or parameter name, or a translation) or in a known credential format (vendor tokens, JWTs, a PEM or PGP private key) reads as `[FILTERED]`; code, ENV lookups, ERB and placeholders are never rewritten, so the text stays usable as the text to replace. `near` is looked for in the lines as they are returned, so text only a filtered value holds is not found, and the context line names the enclosing
|
|
102
|
+
Method-aware code extraction with surrounding class context. Every line returned passes through redaction, so in a file no pattern names a string literal under a secret-named key (8+ characters, unless it is clearly not a credential: a message, URL, path, placeholder, version, date, header, env or parameter name, or a translation) or in a known credential format (vendor tokens, JWTs, a PEM or PGP private key) reads as `[FILTERED]`; code, ENV lookups, ERB and placeholders are never rewritten, so the text stays usable as the text to replace. `near` is looked for in the lines as they are returned, so text only a filtered value holds is not found, and the context line names the enclosing method from those lines too, beside the class or module the match sits in (a `class << self` body counts as its class's).
|
|
103
103
|
|
|
104
104
|
| Parameter | Type | Default | Description |
|
|
105
105
|
|:----------|:-----|:--------|:------------|
|
|
@@ -332,7 +332,7 @@ searches every route, so `controller:"devise/sessions"` or
|
|
|
332
332
|
|
|
333
333
|
### `rails_get_view`
|
|
334
334
|
|
|
335
|
-
View templates with instance variables, Turbo frames, Stimulus controllers, partial locals. Includes schema hints for detected ivars. A template directly under `app/views` is grouped as `(app/views root)`, which `path:` reaches and `controller:` does not.
|
|
335
|
+
View templates with instance variables, Turbo frames, Stimulus controllers, partial locals. Includes schema hints for detected ivars. At `standard` detail the listing opens with the form builders the views call, each with its count (`form_with (2)`), and the ViewComponents they render, from the `views` introspector of the default `full` preset. In an ERB or Ruby template (Phlex, jbuilder) only the Ruby it runs counts, so a render or a helper call inside a comment, a string or page text is not listed. A template directly under `app/views` is grouped as `(app/views root)`, which `path:` reaches and `controller:` does not.
|
|
336
336
|
|
|
337
337
|
| Parameter | Type | Default | Description |
|
|
338
338
|
|:----------|:-----|:--------|:------------|
|
|
@@ -823,7 +823,7 @@ Log correlation reads the last megabyte of the current environment's log, the wi
|
|
|
823
823
|
|
|
824
824
|
PR/commit review with per-file context and warnings.
|
|
825
825
|
|
|
826
|
-
A removed validation is a `validates` line the diff takes out when no line it adds validates the same attribute; an edited `validates` line is not one.
|
|
826
|
+
A removed validation is a `validates` line the diff takes out, or moves into a comment, a heredoc or an `=begin` block, when no line it adds validates the same attribute; an edited `validates` line is not one.
|
|
827
827
|
|
|
828
828
|
A changed migration lists the tables it affects: the table each schema method takes first (`add_column`, `remove_columns`, `rename_index`, `add_foreign_key`, ...), every table `drop_table` drops, a renamed table's new name, and for `create_join_table` and `drop_join_table` the join table, named as Rails names it, and the two tables it joins. The first two are shown with the columns the schema has now.
|
|
829
829
|
|
data/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -62,7 +62,7 @@ if defined?(RailsAiContext) && RailsAiContext.respond_to?(:configure)
|
|
|
62
62
|
end
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
The guard prevents crashes when the gem isn't loaded. `rails ai:doctor` warns about a guard that checks `defined?(RailsAiContext)` alone, since the constant can exist where `configure` does not.
|
|
65
|
+
The guard prevents crashes when the gem isn't loaded. `rails ai:doctor` warns about a guard that checks `defined?(RailsAiContext)` alone, since the constant can exist where `configure` does not. It also warns when no guard covers the `configure` call: a guard inside a comment, or an `if` that ends before the call, does not count.
|
|
66
66
|
|
|
67
67
|
### "Permission denied" during install
|
|
68
68
|
|
|
@@ -132,7 +132,7 @@ The Cursor CLI starts a server with only `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TE
|
|
|
132
132
|
- On Windows, a config that runs `bundle` or `rails-ai-context` itself, without `cmd /d /c`: those are batch files there, which Claude Code, Codex and the other clients that start a server without a shell do not run (`doctor` warns about it). Run the install again; on Windows it writes `cmd /d /c bundle exec rails-ai-context serve`.
|
|
133
133
|
- On Windows, a VS Code entry in a folder of apps whose path holds `&`, `^`, `|`, `<`, `>`, `(` or `)` and no space: VS Code passes `${workspaceFolder}` to cmd unquoted, and cmd reads those characters as its own. Move the folder to a path without them.
|
|
134
134
|
- On macOS or Linux, a config that runs `cmd /d /c ...` or `cmd /c ...`: it was written on Windows, and there is no `cmd` here (`doctor` fails it). Run the install again on this machine, and keep the MCP configs out of git if the team works on both.
|
|
135
|
-
- The Claude Code plugin's server connects with no tools: it tells the session why (no gem for its Ruby, an older one, a Ruby too old, no app, or an app the gem cannot serve) and what to run; `claude --debug` shows the same line as its stderr. With a gem older than 5.38.0 a folder of several apps gets this too; from 5.38.0 the server serves each of them.
|
|
135
|
+
- The Claude Code plugin's server connects with no tools: it tells the session why (no gem for its Ruby, an older one, a Ruby too old, no app, or an app the gem cannot serve) and what to run; `claude --debug` shows the same line as its stderr. With a gem older than 5.38.0 a folder of several apps gets this too; from 5.38.0 the server serves each of them. An engine whose code is all in `lib/` is served through its `test/dummy` or `spec/dummy` app, and with neither it has no app.
|
|
136
136
|
- The Claude Code plugin started in a folder of several apps answers a call with "`app` names the Rails app to answer for, one of: ...": every tool takes `app`, the app's name from that list, which the session-start rules also give. A call whose app's server stops answers with that server's last lines, the same lines `claude --debug` shows; fix what they name, and the app's next call starts its server again. A call to a tool that app's own server does not have names the app and its rails-ai-context version, and one whose Ruby has no copy of the gem names that Ruby and the `gem install` to run with it.
|
|
137
137
|
- The Claude Code plugin's server is listed as failed in `/mcp`, and `claude --debug` shows a `Bundler::GemNotFound` trace in its stderr: Claude Code was started from `bundle exec`, or from a shell holding the `RUBYOPT` or `BUNDLER_SETUP` it sets, for a bundle not installed for the plugin's Ruby, which fails before the plugin runs. Start Claude Code outside `bundle exec`, or unset those two where a shell profile sets them. After a failed start Claude Code skips the server in new sessions for about 15 minutes, so reconnect it in `/mcp` once the cause is fixed.
|
|
138
138
|
|
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require "pathname"
|
|
5
5
|
require "json"
|
|
6
6
|
require "open3"
|
|
7
|
+
require "ripper"
|
|
7
8
|
require "shellwords"
|
|
8
9
|
require "thor/line_editor"
|
|
9
10
|
|
|
@@ -494,10 +495,16 @@ module RailsAiContext
|
|
|
494
495
|
param = block_parameter(block)
|
|
495
496
|
region = (content.byteslice(0, content.lines.first(block.call_line - 1).sum(&:bytesize)).length...
|
|
496
497
|
content.byteslice(0, block.close_offset(content)).length)
|
|
497
|
-
|
|
498
|
-
|
|
498
|
+
comments = Ripper.lex(content).filter_map { |position, type, _| position if type == :on_comment }
|
|
499
|
+
position = region.begin
|
|
500
|
+
while (match = content.match(/^([ \t]*)#[ \t]*#{param}\.#{key}[ \t]*=.*$/, position)) && match.end(0) <= region.end
|
|
501
|
+
if comments.include?([ content[0...match.begin(0)].count("\n") + 1, match[1].bytesize ])
|
|
502
|
+
return [ "#{match.pre_match}#{match[1]}#{param}.#{record.config_line(key, value).delete_prefix('config.')}#{match.post_match}", true ]
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
position = match.end(0)
|
|
499
506
|
end
|
|
500
|
-
[
|
|
507
|
+
[ content, false ]
|
|
501
508
|
end
|
|
502
509
|
|
|
503
510
|
def configure_block(content)
|
|
@@ -519,9 +526,11 @@ module RailsAiContext
|
|
|
519
526
|
def ensure_initializer_guard(content)
|
|
520
527
|
return upgrade_bare_guard(content) if guarded_initializer?(content)
|
|
521
528
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
529
|
+
# Ruby reads a magic comment only above the first token, so the comments that open the file stay above the guard.
|
|
530
|
+
leading = content[/\A(?:[ \t]*(?:#.*)?\r?\n)*/]
|
|
531
|
+
leading = "" if !leading.include?("#") || content.delete_prefix(leading).strip.empty?
|
|
532
|
+
header = leading.empty? ? "" : "#{leading.rstrip}\n\n"
|
|
533
|
+
body = content.delete_prefix(leading)
|
|
525
534
|
body = "#{body}\n" unless body.end_with?("\n")
|
|
526
535
|
|
|
527
536
|
wrapped = "#{header}#{RailsAiContext::Install::InitializerFile::GUARD_LINE}\n#{indent_content(body)}end\n"
|
|
@@ -532,9 +541,13 @@ module RailsAiContext
|
|
|
532
541
|
# guard (written before this gem checked respond_to?(:configure)) in place.
|
|
533
542
|
# No-op if the guard is already the current form.
|
|
534
543
|
def upgrade_bare_guard(content)
|
|
535
|
-
|
|
536
|
-
|
|
544
|
+
lines = content.lines
|
|
545
|
+
RailsAiContext::Install::InitializerFile.bare_guard_lines(content).each do |line|
|
|
546
|
+
lines[line - 1] = lines[line - 1].sub(BARE_GUARD_PATTERN) do
|
|
547
|
+
"#{Regexp.last_match(1)}#{RailsAiContext::Install::InitializerFile::GUARD_LINE}"
|
|
548
|
+
end
|
|
537
549
|
end
|
|
550
|
+
upgraded = lines.join
|
|
538
551
|
[ upgraded, upgraded != content ]
|
|
539
552
|
end
|
|
540
553
|
|
|
@@ -555,7 +568,7 @@ module RailsAiContext
|
|
|
555
568
|
|
|
556
569
|
def guarded_initializer?(content)
|
|
557
570
|
file = RailsAiContext::Install::InitializerFile
|
|
558
|
-
file.
|
|
571
|
+
file.configures?(content) ? file.any_guard_before_configure?(content) : file.guarded?(content)
|
|
559
572
|
end
|
|
560
573
|
|
|
561
574
|
def indent_content(content)
|
|
@@ -15,11 +15,20 @@ module RailsAiContext
|
|
|
15
15
|
# A `/` opens a JS regex literal, a `?` a Ruby character and a `%` a Ruby
|
|
16
16
|
# percent literal only where an operand can start, not after a value.
|
|
17
17
|
OPERAND_START = /[(\[{,=:;!&|?+\-*%~^<>]/
|
|
18
|
+
OPERAND_KEYWORDS = {
|
|
19
|
+
js: %w[return typeof case in of delete void throw new else do yield await instanceof].freeze,
|
|
20
|
+
ruby: %w[return if unless while until and or not when in then else elsif do yield puts p].freeze
|
|
21
|
+
}.freeze
|
|
22
|
+
# `$x`, `@x` and `#x` are names, not keywords.
|
|
23
|
+
NAME_CHAR = /[[:word:]$@#]/
|
|
24
|
+
# The interpolation sigil and the quotes whose strings read it as code.
|
|
25
|
+
INTERPOLATION = { ruby: [ "#", %w[" `] ], js: [ "$", %w[`] ] }.freeze
|
|
18
26
|
# A `/.../` regex literal, JavaScript's or Ruby's: a `/` inside a character class does not close it.
|
|
19
27
|
REGEX_LITERAL = %r{\G/(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\\n])+/[a-z]*}
|
|
20
28
|
RUBY_CHAR = /\G\?(?:\\.|[^\s\w])(?![\w])/
|
|
21
29
|
RUBY_PERCENT = /\G%[qQwWiIrs]?([(\[{<|!\/])/
|
|
22
30
|
MODIFIER = /\A(?:if|unless|while|until|rescue)\b/
|
|
31
|
+
LITERAL_START = %r{["'`/#?%]}
|
|
23
32
|
|
|
24
33
|
module_function
|
|
25
34
|
|
|
@@ -40,7 +49,7 @@ module RailsAiContext
|
|
|
40
49
|
|
|
41
50
|
# The text from the bracket at `open` through the one that closes it,
|
|
42
51
|
# skipping strings, comments and the language's literals; nil when it never closes.
|
|
43
|
-
# ponytail: no heredocs, and
|
|
52
|
+
# ponytail: no heredocs, and interpolation is read as code only in double-quoted and backtick Ruby strings and JS template literals, not in percent or regex literals; a real parser if that bites.
|
|
44
53
|
def span(text, open, comments: nil)
|
|
45
54
|
return nil unless OPENERS.include?(text[open])
|
|
46
55
|
|
|
@@ -65,6 +74,18 @@ module RailsAiContext
|
|
|
65
74
|
nil
|
|
66
75
|
end
|
|
67
76
|
|
|
77
|
+
# `text` with each comment blanked to spaces, newlines kept, so every offset still points at the source.
|
|
78
|
+
def without_comments(text, comments:)
|
|
79
|
+
out = text.dup
|
|
80
|
+
i = 0
|
|
81
|
+
while (i = text.index(LITERAL_START, i))
|
|
82
|
+
length, kind = literal_at(text, i, comments)
|
|
83
|
+
out[i, length] = text[i, length].tr("^\n", " ") if kind == :comment
|
|
84
|
+
i += length || 1
|
|
85
|
+
end
|
|
86
|
+
out
|
|
87
|
+
end
|
|
88
|
+
|
|
68
89
|
# The inside of the group `text` opens with, when nothing but a trailing
|
|
69
90
|
# modifier (`... if x`) follows it; otherwise `text` as it is.
|
|
70
91
|
def unwrap(text, comments: :ruby)
|
|
@@ -78,12 +99,12 @@ module RailsAiContext
|
|
|
78
99
|
# The length and kind of a string, comment or other literal starting at i, or nil.
|
|
79
100
|
def literal_at(text, i, comments)
|
|
80
101
|
char = text[i]
|
|
81
|
-
return [ string_length(text, i), :string ] if QUOTES.include?(char)
|
|
102
|
+
return [ string_length(text, i, comments), :string ] if QUOTES.include?(char)
|
|
82
103
|
|
|
83
104
|
if comments && (match = text.match(COMMENTS[comments], i)) && match.begin(0) == i
|
|
84
105
|
return [ match[0].length, :comment ]
|
|
85
106
|
end
|
|
86
|
-
return nil unless "/?%".include?(char) && operand_start?(text, i)
|
|
107
|
+
return nil unless "/?%".include?(char) && operand_start?(text, i, comments)
|
|
87
108
|
|
|
88
109
|
match =
|
|
89
110
|
if char == "/" then text.match(REGEX_LITERAL, i)
|
|
@@ -95,15 +116,46 @@ module RailsAiContext
|
|
|
95
116
|
[ char == "%" ? percent_length(text, i, match) : match[0].length, :literal ]
|
|
96
117
|
end
|
|
97
118
|
|
|
98
|
-
def string_length(text, i)
|
|
119
|
+
def string_length(text, i, comments = nil)
|
|
120
|
+
sigil, quotes = INTERPOLATION[comments]
|
|
121
|
+
return interpolated_length(text, i, sigil, comments) if quotes&.include?(text[i])
|
|
122
|
+
|
|
99
123
|
match = text[i..][STRING]
|
|
100
124
|
match ? match.length : text.length - i
|
|
101
125
|
end
|
|
102
126
|
|
|
103
|
-
|
|
127
|
+
# A string whose `#{...}` or `${...}` is read as code, nested strings included.
|
|
128
|
+
def interpolated_length(text, i, sigil, comments)
|
|
129
|
+
quote = text[i]
|
|
130
|
+
j = i + 1
|
|
131
|
+
while j < text.length
|
|
132
|
+
char = text[j]
|
|
133
|
+
if char == "\\"
|
|
134
|
+
j += 2
|
|
135
|
+
elsif char == quote
|
|
136
|
+
return j - i + 1
|
|
137
|
+
elsif char == sigil && text[j + 1] == "{"
|
|
138
|
+
group = span(text, j + 1, comments: comments) or return text.length - i
|
|
139
|
+
j += 1 + group.length
|
|
140
|
+
else
|
|
141
|
+
j += 1
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
text.length - i
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def operand_start?(text, i, comments = nil)
|
|
104
148
|
j = i - 1
|
|
105
149
|
j -= 1 while j >= 0 && text[j].match?(/\s/)
|
|
106
|
-
j.negative? || text[j].match?(OPERAND_START)
|
|
150
|
+
j.negative? || text[j].match?(OPERAND_START) || after_keyword?(text, j, comments)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# `x.return / 2` calls a method named like the keyword, so its `/` divides.
|
|
154
|
+
def after_keyword?(text, last, comments)
|
|
155
|
+
keywords = OPERAND_KEYWORDS[comments] or return false
|
|
156
|
+
first = last + 1
|
|
157
|
+
first -= 1 while first.positive? && text[first - 1].match?(NAME_CHAR)
|
|
158
|
+
keywords.include?(text[first..last]) && (first.zero? || text[first - 1] != ".")
|
|
107
159
|
end
|
|
108
160
|
|
|
109
161
|
# `%w[a )]`: the literal runs to the delimiter that closes it, nesting for
|
|
@@ -52,7 +52,7 @@ module RailsAiContext
|
|
|
52
52
|
|
|
53
53
|
# A monorepo's root runs the app its bin/rails boots, as a folder holding that one app: init sets it up from the root,
|
|
54
54
|
# whose .mcp.json a session started there reads (ADR-0005).
|
|
55
|
-
booted = binstub_app(result.root)
|
|
55
|
+
booted = binstub_app(result.root) || dummy_below(result.root)
|
|
56
56
|
if booted
|
|
57
57
|
walk = Result.new(root: booted, walked: :down, below: [ booted ], base: result.root, explicit: false, cwd: result.cwd)
|
|
58
58
|
return Target.new(kind: :app, root: booted, apps: [], resolution: walk)
|
|
@@ -81,10 +81,19 @@ module RailsAiContext
|
|
|
81
81
|
end
|
|
82
82
|
private_class_method :binstub_app
|
|
83
83
|
|
|
84
|
+
# An engine's test/dummy or spec/dummy app, as dummy_app finds one where bin/rails names none (Rails 4.2 and 5.0 wrote no APP_PATH).
|
|
85
|
+
def self.dummy_below(root)
|
|
86
|
+
[ File.join(root, "test", "dummy"), File.join(root, "spec", "dummy") ].find { |dir| EntryBoot.app_present?(dir, allow_source_only: true) }
|
|
87
|
+
end
|
|
88
|
+
private_class_method :dummy_below
|
|
89
|
+
|
|
84
90
|
# The folder bin/rails's APP_PATH names, nil where it names none.
|
|
85
91
|
def self.app_path_dir(root)
|
|
86
|
-
named = File.binread(File.join(root, "bin", "rails"), 4096)
|
|
87
|
-
|
|
92
|
+
named, anchor = File.binread(File.join(root, "bin", "rails"), 4096)
|
|
93
|
+
.match(/^\s*APP_PATH\s*=\s*File\.expand_path\(\s*["']([^"'#]+)["']\s*,\s*(__dir__|__FILE__)\s*\)/)&.captures
|
|
94
|
+
# Rails 5.1 expanded from __FILE__, which starts at bin/rails itself rather than at bin/.
|
|
95
|
+
base = anchor == "__FILE__" ? File.join(root, "bin", "rails") : File.join(root, "bin")
|
|
96
|
+
named && File.dirname(File.expand_path(named.force_encoding(root.encoding), base), 2)
|
|
88
97
|
rescue SystemCallError, IOError
|
|
89
98
|
nil
|
|
90
99
|
end
|
|
@@ -156,10 +156,21 @@ module RailsAiContext
|
|
|
156
156
|
# newlines so the rest of the file parses at its written indentation.
|
|
157
157
|
def neutralize_erb(content)
|
|
158
158
|
content.gsub(RailsAiContext::ErbSource::TAG) do |tag|
|
|
159
|
-
(tag.start_with?("<%=") ? ERB_SENTINEL + Regexp.last_match(1).to_s
|
|
159
|
+
(tag.start_with?("<%=") ? ERB_SENTINEL + erb_default(Regexp.last_match(1).to_s).to_s : "") + ("\n" * tag.count("\n"))
|
|
160
160
|
end
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
+
def erb_default(code)
|
|
164
|
+
code[ERB_DEFAULT, 2] || env_fetch_default(code)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# `ENV.fetch("X", "mysql2")` or `ENV.fetch("X") { "mysql2" }`: the literal is what runs with the variable unset.
|
|
168
|
+
def env_fetch_default(code)
|
|
169
|
+
statements = AstCache.parse_string(code).value.statements.body
|
|
170
|
+
default = Introspectors::EnvReferences.fetch_default(statements.first) if statements.size == 1
|
|
171
|
+
default.unescaped if default.is_a?(Prism::StringNode) && default.unescaped.match?(/\A[\w.-]+\z/)
|
|
172
|
+
end
|
|
173
|
+
|
|
163
174
|
def computed?(value)
|
|
164
175
|
value.to_s.include?(ERB_SENTINEL)
|
|
165
176
|
end
|