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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +120 -0
  3. data/CONTRIBUTING.md +1 -0
  4. data/docs/ARCHITECTURE.md +2 -2
  5. data/docs/CLI.md +4 -1
  6. data/docs/GUIDE.md +3 -3
  7. data/docs/INTROSPECTORS.md +10 -4
  8. data/docs/SETUP.md +2 -1
  9. data/docs/TOOLS.md +3 -3
  10. data/docs/TROUBLESHOOTING.md +2 -2
  11. data/lib/generators/rails_ai_context/install/install_generator.rb +22 -9
  12. data/lib/rails_ai_context/brackets.rb +58 -6
  13. data/lib/rails_ai_context/cli/app_root.rb +12 -3
  14. data/lib/rails_ai_context/database_yml.rb +12 -1
  15. data/lib/rails_ai_context/doctor.rb +24 -1
  16. data/lib/rails_ai_context/erb_source.rb +41 -26
  17. data/lib/rails_ai_context/gem_lock.rb +4 -1
  18. data/lib/rails_ai_context/install/initializer_file.rb +111 -12
  19. data/lib/rails_ai_context/introspectors/action_resolver.rb +14 -18
  20. data/lib/rails_ai_context/introspectors/convention_introspector.rb +3 -4
  21. data/lib/rails_ai_context/introspectors/declared_constant.rb +8 -0
  22. data/lib/rails_ai_context/introspectors/devops_introspector.rb +30 -6
  23. data/lib/rails_ai_context/introspectors/env_references.rb +16 -0
  24. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -0
  25. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +1 -0
  26. data/lib/rails_ai_context/introspectors/job_introspector.rb +37 -9
  27. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +15 -6
  28. data/lib/rails_ai_context/introspectors/model_introspector.rb +11 -2
  29. data/lib/rails_ai_context/introspectors/module_aliases.rb +1 -0
  30. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +6 -2
  31. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +22 -4
  32. data/lib/rails_ai_context/introspectors/performance_introspector.rb +41 -2
  33. data/lib/rails_ai_context/introspectors/recurring_schedules.rb +18 -2
  34. data/lib/rails_ai_context/introspectors/render_calls.rb +40 -2
  35. data/lib/rails_ai_context/introspectors/route_introspector.rb +6 -3
  36. data/lib/rails_ai_context/introspectors/side_effects.rb +1 -1
  37. data/lib/rails_ai_context/introspectors/source_scan.rb +2 -1
  38. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +57 -28
  39. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +24 -1
  40. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +108 -20
  41. data/lib/rails_ai_context/introspectors/view_introspector.rb +50 -25
  42. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +98 -22
  43. data/lib/rails_ai_context/ruby_source.rb +102 -0
  44. data/lib/rails_ai_context/schema_version.rb +22 -2
  45. data/lib/rails_ai_context/tools/analyze_feature.rb +15 -8
  46. data/lib/rails_ai_context/tools/diagnose.rb +6 -1
  47. data/lib/rails_ai_context/tools/generate_test.rb +27 -14
  48. data/lib/rails_ai_context/tools/get_concern.rb +48 -36
  49. data/lib/rails_ai_context/tools/get_context.rb +44 -14
  50. data/lib/rails_ai_context/tools/get_controllers.rb +36 -31
  51. data/lib/rails_ai_context/tools/get_conventions.rb +57 -42
  52. data/lib/rails_ai_context/tools/get_edit_context.rb +23 -7
  53. data/lib/rails_ai_context/tools/get_env.rb +58 -13
  54. data/lib/rails_ai_context/tools/get_helper_methods.rb +9 -1
  55. data/lib/rails_ai_context/tools/get_job_pattern.rb +14 -14
  56. data/lib/rails_ai_context/tools/get_model_details.rb +4 -6
  57. data/lib/rails_ai_context/tools/get_partial_interface.rb +115 -62
  58. data/lib/rails_ai_context/tools/get_service_pattern.rb +21 -11
  59. data/lib/rails_ai_context/tools/get_stimulus.rb +2 -1
  60. data/lib/rails_ai_context/tools/get_test_info.rb +6 -3
  61. data/lib/rails_ai_context/tools/get_view.rb +28 -10
  62. data/lib/rails_ai_context/tools/migration_advisor.rb +7 -3
  63. data/lib/rails_ai_context/tools/review_changes.rb +19 -8
  64. data/lib/rails_ai_context/tools/search_code.rb +23 -7
  65. data/lib/rails_ai_context/tools/validate_semantics.rb +53 -45
  66. data/lib/rails_ai_context/unbooted_settings.rb +9 -4
  67. data/lib/rails_ai_context/version.rb +1 -1
  68. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94b2634b5384da11f4a765e7d33f9bc07791e66b6303fabe6cf35d43db780b33
4
- data.tar.gz: 802b9059f8fffb0db30d74f575f4b394cb7a8c28d342e3016d16330c11541767
3
+ metadata.gz: 9009c01e492d1f80697f24c8a405105a672e9658600ea91a2900e927ae7e393c
4
+ data.tar.gz: 7b1c562d16a0eb4dcbb2e2709ef3aa584770c49cb29e463ce643d7aa3b822aa9
5
5
  SHA512:
6
- metadata.gz: ad1ba1f302bdaf4fdaa25c254d3a6101a4d576dcb6426811f513579d21266a1c48f7e5a353c8a55a37c8ef74c2cb382143a286030569fa0b9256a6514d44e1ee
7
- data.tar.gz: c15145c44717a97495ed08a5567327b68c6e6d1c98109e042aaab4bb2a71528ec3569d51d1ad7c33ccbc75e7d8c7e354e46ecd6169df1c09f18d2092fde57afe
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 AST parsing** replaced all regex-based Ruby source parsing in v5.2.0.
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 walk names the app it chose on stderr, as
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` has no guard, or guards on `defined?(RailsAiContext)` alone |
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 |
@@ -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`), and the names Ruby tries for a reference (`candidate_names`) |
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. On Windows the plugin serves one 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 class and method from those lines too.
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
 
@@ -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
- updated = content[region].sub(/^([ \t]*)#[ \t]*#{param}\.#{key}[ \t]*=.*$/) do
498
- "#{Regexp.last_match(1)}#{param}.#{record.config_line(key, value).delete_prefix('config.')}"
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
- [ "#{content[0...region.begin]}#{updated}#{content[region.end..]}", content[region] != updated ]
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
- header_match = content.match(/\A# frozen_string_literal: true\n(?:\n)?/)
523
- header = header_match ? "# frozen_string_literal: true\n\n" : ""
524
- body = header_match ? content.delete_prefix(header_match[0]) : content
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
- upgraded = content.sub(BARE_GUARD_PATTERN) do
536
- "#{Regexp.last_match(1)}#{RailsAiContext::Install::InitializerFile::GUARD_LINE}"
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.guarded?(content) || file.any_guard_before_configure?(content)
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 a quote or slash inside `#{...}` interpolation ends the literal early; a real parser if that bites.
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
- def operand_start?(text, i)
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)[/^\s*APP_PATH\s*=\s*File\.expand_path\(\s*["']([^"'#]+)["']\s*,\s*__dir__\s*\)/, 1]
87
- named && File.dirname(File.expand_path(named.force_encoding(root.encoding), File.join(root, "bin")), 2)
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[ERB_DEFAULT, 2].to_s : "") + ("\n" * tag.count("\n"))
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