rails-ai-context 5.30.2 → 5.31.0

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +173 -0
  3. data/docs/COMPATIBILITY.md +21 -7
  4. data/docs/CONFIGURATION.md +3 -0
  5. data/docs/GUIDE.md +7 -1
  6. data/docs/INTROSPECTORS.md +6 -1
  7. data/docs/SECURITY.md +23 -1
  8. data/docs/TOOLS.md +11 -2
  9. data/lib/rails_ai_context/action_filters.rb +2 -3
  10. data/lib/rails_ai_context/base_mixins.rb +44 -16
  11. data/lib/rails_ai_context/concern_macros.rb +698 -92
  12. data/lib/rails_ai_context/concern_membership.rb +6 -4
  13. data/lib/rails_ai_context/concern_paths.rb +29 -3
  14. data/lib/rails_ai_context/configuration.rb +2 -2
  15. data/lib/rails_ai_context/git_ignore.rb +13 -11
  16. data/lib/rails_ai_context/introspectors/action_presence.rb +5 -3
  17. data/lib/rails_ai_context/introspectors/action_resolver.rb +2 -1
  18. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +198 -18
  19. data/lib/rails_ai_context/introspectors/controller_introspector.rb +3 -3
  20. data/lib/rails_ai_context/introspectors/declared_constant.rb +15 -6
  21. data/lib/rails_ai_context/introspectors/job_introspector.rb +55 -10
  22. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +3 -3
  23. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +28 -1
  24. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +3 -1
  25. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +21 -28
  26. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  27. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  28. data/lib/rails_ai_context/introspectors/model_introspector.rb +277 -118
  29. data/lib/rails_ai_context/introspectors/source_scan.rb +5 -3
  30. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +13 -4
  31. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +104 -21
  32. data/lib/rails_ai_context/method_name.rb +31 -0
  33. data/lib/rails_ai_context/output_guard.rb +6 -4
  34. data/lib/rails_ai_context/redaction.rb +8 -2
  35. data/lib/rails_ai_context/safe_file.rb +4 -1
  36. data/lib/rails_ai_context/safe_path.rb +17 -4
  37. data/lib/rails_ai_context/server.rb +26 -7
  38. data/lib/rails_ai_context/tools/base_tool.rb +11 -2
  39. data/lib/rails_ai_context/tools/get_callbacks.rb +14 -6
  40. data/lib/rails_ai_context/tools/get_concern.rb +1 -1
  41. data/lib/rails_ai_context/tools/get_conventions.rb +5 -7
  42. data/lib/rails_ai_context/tools/get_job_pattern.rb +8 -2
  43. data/lib/rails_ai_context/tools/get_model_details.rb +4 -3
  44. data/lib/rails_ai_context/tools/get_schema.rb +4 -0
  45. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  46. data/lib/rails_ai_context/tools/query.rb +122 -28
  47. data/lib/rails_ai_context/tools/search_code.rb +64 -22
  48. data/lib/rails_ai_context/tools/search_docs.rb +4 -5
  49. data/lib/rails_ai_context/tools/security_scan.rb +3 -1
  50. data/lib/rails_ai_context/version.rb +1 -1
  51. data/lib/rails_ai_context.rb +1 -1
  52. data/server.json +1 -1
  53. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49493277fcbe8b2da2d183de723ebf3d5f4c85101f5eff5b6a0c89a1ae4340a9
4
- data.tar.gz: ebd28661e4d369b526027b8705c599372383796003287160a460ec3e99990632
3
+ metadata.gz: 44b4fb64c009d96449f8f05375947d6713b07d0a45c7f27e779e0ac271c0bfa6
4
+ data.tar.gz: d0045d00ef00250f97eeae2ff105b09c7f991a3746c9198bc8126b3e79d8dc2a
5
5
  SHA512:
6
- metadata.gz: 3a98be099ee6b23ded854a1820714d9450d8ce304dd08c18d12182b7a8f15097f0ca5eb2c3c0db122b313057aed0a9ec3b44b38aae1c0e1a231d1f41b455c6e6
7
- data.tar.gz: 0be3021416fc068c9e93bfaabba548a22cb914c6393f1daf6ef9a7892045cf1984efad3d22de132c29b3d83081e796a5f74e868fc8b4f94883912ff7f4562f75
6
+ metadata.gz: e60a1a843d881a90404c7f87b6a29e829a60210195375c94a90151b82850d749820828b752e9e6f5f60b22dd92bf948bb8275f207c7df801ce954bd9a53705cd
7
+ data.tar.gz: 4aa95471cb0a3a4ad1cec127db6322b9b0b5d1f39f00826b1c73dd246cf0acfadc51e14f3e9f16b1cd6f3602acb618ee520eadc2b674825d2f95e675dfa7e6c1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,179 @@ 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.31.0] - 2026-10-05
9
+
10
+ ### Changed
11
+
12
+ - **A committed placeholder is not a secret.** A file whose name ends in
13
+ `.example`, `.sample`, `.template` or `.dist` (`.env.example`) is readable by
14
+ `rails_get_edit_context` and the other file tools, although the default
15
+ `.env.*` pattern matches it, and `doctor` no longer lists it as unread or
16
+ says to gitignore it. A `sensitive_patterns` entry that names the file
17
+ exactly, with no glob characters, still blocks it, and so does a path
18
+ pattern with a `/` (`.ssh/*` keeps `.ssh/id_rsa.template` blocked).
19
+ Redaction treats the four suffixes alike. `rails_search_code` now drops
20
+ sensitive files from ripgrep's output where it used to exclude them by glob,
21
+ so a sensitive file ripgrep cannot open is not reported as an error and does
22
+ not send a no-match search through the Ruby fallback. (#267)
23
+ - **`rails_query` on a SQLite file stops at `query_timeout`.** The query runs
24
+ in a child process with its own read-only connection, which is stopped at the
25
+ deadline. sqlite3 2.x's `statement_timeout=` interrupts any statement of more
26
+ than a few thousand steps whatever the value, and the extension holds the GVL
27
+ while a statement runs, so no in-process limit exists. The child takes the
28
+ app's `extensions:` and waits on a locked database as the app would. An
29
+ in-memory database, a platform without `fork`, or a query that needs a
30
+ function, virtual-table module, collation or encryption key only the app's
31
+ own connection has runs in-process; the table and EXPLAIN answers then say it
32
+ ran without a time limit, and CSV output stays plain data. (#256)
33
+ - **`rails_get_callbacks` lists callbacks in the order Rails runs them**: base
34
+ classes first, a concern's callbacks where its `include` line stands, then
35
+ the model's own, and a `before_` or `around_` callback with `prepend: true`
36
+ first. `after_commit` and `after_rollback` list last declared first, as
37
+ Rails runs them, unless `run_after_transaction_callbacks_in_order_defined`
38
+ is on (`load_defaults 7.1` or later). The static tier reads that from
39
+ `config/application.rb` and the initializers, the last line that sets it
40
+ winning, and when the config does not tell, `rails_get_callbacks` and
41
+ `rails_get_model_details` say so. A method declared twice under one callback
42
+ type shows the declaration Rails keeps, the later one, which corrects the
43
+ 5.30.0 entry that said each declaration is shown. A declaration inside a
44
+ method body counts where the class calls the method, and not at all when
45
+ nothing does, so an uncalled method that redeclares a callback no longer
46
+ hides it. A call counts written bare or as `self.name`, made inside a class
47
+ method it runs, or made from a concern's `included` block. A module joins a
48
+ class chain once, at the first place the chain adds it: the outermost class
49
+ that adds it, at its first include there, which can be a call to a class
50
+ method that includes it. A Concern's `included` block runs there only; a
51
+ plain `self.included` hook runs on every include. A call runs the definition
52
+ Ruby's lookup finds as of the call: a module the class prepends to its
53
+ singleton class, the class's own class methods, then the modules it extends
54
+ and its concerns' class methods, the last added first, then the same for
55
+ each base, then what every model has: class methods an initializer writes in
56
+ `ActiveRecord::Base.class_eval` or a reopened base, the file Rails loads
57
+ later winning (Canvas's `validates_locale` adds its `before_validation` to
58
+ Account, User and Course), then the modules mixed into the base. One an
59
+ initializer writes on `ApplicationRecord` replaces ApplicationRecord's own.
60
+ A class's own class methods are its `def self.`, an `alias_method` in
61
+ `class << self` (it runs what its original name ran at the alias), and those
62
+ a Concern's `included` block or a hook writes on the class (`def self.x` or
63
+ `class << self`, in the block, in `base.class_eval` or in `class << base`),
64
+ each from its line where that code runs. A module counts from the line
65
+ adding it, where that line runs: an `extend`, `singleton_class.include` or
66
+ `singleton_class.prepend` (which counts as prepended) in the class, in a
67
+ hook or in a Concern's `included` block, and an `extend` in a class method a
68
+ call reaches (an `acts_as_x` that extends its methods, whose `self.extended`
69
+ hook then runs); a plain module's nested `ClassMethods` counts only when
70
+ something extends it. A module a hook includes runs its own hooks. Two
71
+ concerns' modules of one name (each concern's `ClassMethods`) stay two: each
72
+ name is the constant it resolves to where it is written, and one that
73
+ resolves to nothing no longer hides the other. A `super` runs the next
74
+ definition, at the `super`. An instance method or a nested class's method of
75
+ the same name never runs. A module a class method includes joins only when a
76
+ call reaches that method, and only calls after it see its methods. A nested
77
+ class's callbacks are its own, and a module nested in the model's file reads
78
+ like any concern: `include` runs its `included` block, `extend` its methods
79
+ and `self.extended` only. A module a plugin's lib loads by a glob `require`
80
+ is read from beside the file naming it, and from its definition rather than
81
+ an empty namespace stub above it, so OpenProject's journalized models list
82
+ `save_journals`. A Mongoid document's own callbacks list in the same run
83
+ order, one in a class method where the document calls it. Neither tier
84
+ evaluates `send`, `define_singleton_method` or a module included in
85
+ `class << self` for this, so the list can miss what they register or show
86
+ the definition they replace. (#253)
87
+ - **A lambda or Proc given to `queue_as` reads as what ActiveJob does with
88
+ it.** ActiveJob never calls it; it names the queue after the Proc's text.
89
+ Both tiers now say so, with the source, where the booted tier printed the
90
+ Proc's address and the static tier called it computed. A `queue_as` block is
91
+ the computed case and now shows its source too; a block argument
92
+ (`queue_as(&pick)`) reads as computed in both tiers. The 5.30.0 entry used
93
+ `queue_as -> { ... }` as its example of a computed queue; that was wrong.
94
+ (#271)
95
+ - **An MCP stdio session carries nothing but JSON-RPC on stdout for its whole
96
+ life.** Output written during a tool call (a logger built on first use, as
97
+ Sidekiq's is, `puts`, a child process) goes to stderr; only boot was guarded
98
+ before. (#255)
99
+ - **The booted `rails_security_scan` parses in its own process.** Brakeman's
100
+ forked parse workers could not return a parse error from an app that loads
101
+ web-console, and the scan failed where the static tier scanned. (#257)
102
+
103
+ ### Fixed
104
+
105
+ - **A concern's declarations name the module the include resolves to.**
106
+ `rails_get_callbacks` on OpenProject's WorkPackage lists
107
+ `after_save :save_journals` from `Acts::Journalized::SaveHooks`, where it
108
+ said `SaveHooks`.
109
+ - **A module declared in its outer module's file is read from there.**
110
+ Canvas's `Role::AssociationHelper` lives in role.rb, and its `included`
111
+ hook adds `before_save :resolve_cross_account_role` to Enrollment and its
112
+ subclasses, AccountUser and RoleOverride; the static tier called the
113
+ module unread and missed the callback. `rails_get_callbacks`,
114
+ `rails_get_model_details` and the controller filters and actions find it
115
+ there and read that module, not the outer class.
116
+ - **`rails_get_callbacks` lists models of one callback count by name**, so a
117
+ model gaining a callback no longer moves the sections around it.
118
+ - **A model macro written over `*args` reads each call's own arguments.**
119
+ The list binds to the call's positionals past the method's other
120
+ parameters, less the options hash the body takes off the end
121
+ (`args.extract_options!`), plus literals its leading statements push. A
122
+ block over the list is read once per item, so Canvas's
123
+ `validates_locale :locale, :browser_locale, allow_nil: true` lists an
124
+ inclusion validation on each field with its own `if: :<field>_changed?`, in
125
+ place of the macro's own row and one on a computed `field`. An options
126
+ hash, list or parameter the method changes in place (`options[:x] = 1`,
127
+ `options[:x] ||= 1`, `reverse_merge!`, `delete`) is not read as the call's
128
+ value. A declaration it decides, the method name included (`before_save
129
+ name` after `name.strip!`), is held back and listed under "Not decided from
130
+ the source (the method name or a condition)", the heading that was "Only
131
+ under a condition the source does not decide".
132
+ - **A `load_defaults` written after `belongs_to_required_by_default = false`
133
+ turns the default back on** in the static tier, as Rails does, so the
134
+ implicit presence of a required `belongs_to` is listed. A literal assignment
135
+ used to win wherever it stood. A value the source cannot evaluate
136
+ (`ENV.fetch(...) == "true"`), app-wide or in the class, makes the presence
137
+ conditional on it, named in the answer. A literal `nil` reads as off, as
138
+ Rails reads it, here and for the commit-order setting. A `belongs_to` with
139
+ `required: nil` is optional, and `required:` decides over `optional:` when
140
+ both are written, as in Rails.
141
+ - **`rails_get_callbacks` prints `on:`** for validation callbacks and any
142
+ callback whose type does not already name the event, before `if:` and
143
+ `unless:`. (#252)
144
+ - **`rails_performance_check` with `model` and `category` counts only what it
145
+ lists.** (#265)
146
+ - **`rails_search_docs` caches a fetched guide whole.** A guide with non-ASCII
147
+ text failed to write in the booted tier and left an empty file that read as a
148
+ cached guide for 24 hours; the write is now binary and atomic, and an empty
149
+ cache file is a miss. (#266)
150
+ - **`rails_search_code` trace finds one definition and no sibling of itself**:
151
+ `qa_ping` no longer matches `def qa_ping?`, `qa_ping!` or `qa_ping=`, and
152
+ `def self.qa_ping` is left out of its own sibling list, where a setter now
153
+ appears. Every method-body lookup (concerns, callbacks, actions, conventions)
154
+ ends a name the same way, and ripgrep and the Ruby fallback agree on
155
+ non-ASCII text, where any character outside ASCII is part of the name, as
156
+ Ruby reads it. (#258)
157
+ - **`rails_search_code` call sites skip comment lines** with the rule trace
158
+ uses, by file type: `#` in Ruby, `//` and `/*` in JS and TS, `/*` in CSS and
159
+ `//` too in SCSS, Sass and Less, `-#` in Haml, `/` in Slim. A stylesheet
160
+ line that starts with a `#id` selector is not a call site either. A line
161
+ holding a live ERB tag is a call site, and a spaced setter call
162
+ (`obj.qa_ping = 1`) is not a call of `qa_ping`. (#269)
163
+ - **Both search backends read the same excludes file.** The Ruby fallback read
164
+ `core.excludesFile` from the repository's own config, which ripgrep does not.
165
+ It now reads what ripgrep 15 reads: `~/.gitconfig`, then the XDG git config,
166
+ then the default ignore file. (#268)
167
+ - **A `db/structure.sql` table that `INHERITS` reads its parent's columns
168
+ first, then its own**, with multiple parents, chains, a parent defined later
169
+ in the file, and `ALTER COLUMN ... SET NOT NULL`/`SET DEFAULT`. A parent in
170
+ another schema resolves; one the file does not define is named. An index, key
171
+ or foreign key lands on the table its statement names, schema included. The
172
+ one-line form read its column type as `integer) INHERITS (...`. (#259)
173
+ - **A Stimulus controller under `app/javascript/controllers` keeps its
174
+ identifier** when another `controllers` directory holds the same path, and a
175
+ nested `controllers` directory stays in the name (`admin--controllers--x`).
176
+ (#264)
177
+ - **`query_allowed_columns` exempts a column from result redaction too**, as
178
+ it does from the pre-query check. Columns declared with `encrypts` stay
179
+ redacted. (#270)
180
+
8
181
  ## [5.30.2] - 2026-10-01
9
182
 
10
183
  ### Changed
@@ -217,7 +217,12 @@ Proof sources:
217
217
  partition marker, and Discourse, Mastodon, OpenFoodNetwork and Forem
218
218
  answering as before. In the v5.30.2 release QA: 14 real apps answered by
219
219
  v5.30.1 and by the release branch side by side, and the lab regressions on
220
- SQLite in all three tiers, PostgreSQL and MySQL.
220
+ SQLite in all three tiers, PostgreSQL and MySQL. In the v5.31.0 release QA:
221
+ a private Rails 8.0 API app booted and static, and Discourse, Mastodon,
222
+ Forem, OpenFoodNetwork, OpenProject, Consul, Canvas and Errbit answered by
223
+ v5.30.2 and by the release branch side by side, every changed answer
224
+ checked against the app's source; the lab regressions on SQLite in all three
225
+ tiers, PostgreSQL and MySQL; and the unit suite on Ruby 3.1 with Rails 7.0.
221
226
  2. Non-crash coverage for every built-in tool including `get_view` in
222
227
  `spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
223
228
  (ivar cross-check, render-form detection, partial interfaces) verified
@@ -277,12 +282,21 @@ Postgres instance in `spec/e2e/postgres_install_spec.rb`, opt-in via
277
282
  stay marked `[UNAVAILABLE]`. A concern whose file cannot be found, a gem's
278
283
  module for example, is named under `Concerns` as not read.
279
284
  - **Callbacks are what the file declares.** In both tiers the callback list is
280
- what the model file, the app concerns it includes and the classes it
281
- inherits from declare. A callback a
282
- gem registers on include without an `include` line in the model file (Devise,
283
- counter caches, attachment purges) is not listed. Within one type the order
284
- is declaration order, the model file first and then its concerns, not the
285
- order Rails registered them in; the order across types is Rails' event order.
285
+ what the model file, the app concerns it includes and the classes it inherits
286
+ from declare. A callback a gem registers on include without an `include` line
287
+ in the model file (Devise, counter caches, attachment purges) is not listed.
288
+ Within one type the order is the order Rails runs them: base classes first, a
289
+ concern's callbacks where its `include` line stands, then the model's own,
290
+ and a `before_` or `around_` callback with `prepend: true` first.
291
+ `after_commit` and `after_rollback` run last declared first unless
292
+ `run_after_transaction_callbacks_in_order_defined` is on (`load_defaults 7.1`
293
+ or later; Rails 7.0 has no such setting). The booted tier reads the setting;
294
+ the static tier reads `load_defaults` and any assignment in
295
+ `config/application.rb` or an initializer, the last line that sets it
296
+ winning, and when those do not say, keeps declaration order and the answer
297
+ says so. The static tier does not read `config/environments/*.rb` for this
298
+ setting or for `belongs_to_required_by_default`, so a value set per
299
+ environment is missed. The order across types is Rails' event order.
286
300
  - **Concern-style Mongoid documents in runtime results.** Mongoid documents
287
301
  are invisible to ActiveRecord reflection, so `ModelIntrospector#call` falls
288
302
  back to the same source-parsing pass used in the static tier even when the
@@ -213,6 +213,9 @@ fits neither that shape nor the conventional one.
213
213
  |:-------|:-----|:--------|:------------|
214
214
  | `sensitive_patterns` | Array | 34 patterns | File patterns blocked from search/read (`.env*`, `*.env`, `.envrc`, `*.key`, `*.pem`, `config/credentials.yml.enc`, `config/application.yml`, `config/settings.local.yml`, `.ssh/*`, etc.) |
215
215
 
216
+ A placeholder such as `.env.example` stays readable unless a path pattern or
217
+ its exact name blocks it; see [SECURITY.md](SECURITY.md).
218
+
216
219
  ### Extensibility
217
220
 
218
221
  | Option | Type | Default | Description |
data/docs/GUIDE.md CHANGED
@@ -826,7 +826,13 @@ rails_get_callbacks(model: "Order", detail: "full")
826
826
  → Order's callbacks with the actual method source code for each callback
827
827
  ```
828
828
 
829
- **Returns:** Callbacks grouped by type, in Rails event order. Includes concern-provided callbacks, with their bodies read from the concern file. Within one type the order is declaration order, not the order Rails registered them in.
829
+ **Returns:** Callbacks grouped by type, in Rails event order. Includes
830
+ concern-provided callbacks, with their bodies read from the concern file.
831
+ Within one type the order is the order Rails runs them, base classes first, a
832
+ `before_` or `around_` callback with `prepend: true` first, and
833
+ `after_commit`/`after_rollback` last declared first unless the app runs them in
834
+ order (`load_defaults 7.1`). A method declared again shows once with the later
835
+ declaration's conditions.
830
836
 
831
837
  ### rails_get_helper_methods
832
838
 
@@ -186,7 +186,7 @@ It runs a single-pass Dispatcher that walks the AST once and feeds events to all
186
186
  | CallbacksListener | All AR callback types including `around_*`, `after_touch`, `after_initialize` and `after_find`; `after_commit` with `on:` resolution; a callback object by its constant, a block as `[inline_block]`; `if:`/`unless:` kept as the source wrote them, and the options of an enclosing `with_options` block |
187
187
  | MacrosListener | `encrypts`, `normalizes`, `delegate`, `has_secure_password`, `serialize`, `store`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `generates_token_for`, `attribute` |
188
188
  | MethodsListener | `def`/`def self.`, visibility tracking, parameter extraction, `class << self`; `include_initialize: true` adds the constructor a caller reports on its own |
189
- | MixinsListener | `include`, `prepend`, `extend`, flagging the ones that reach the ancestor chain |
189
+ | MixinsListener | `include`, `prepend`, `extend`, `singleton_class.include` and `singleton_class.prepend` (`singleton_class.extend` is not read), flagging the ones that reach the ancestor chain, as reflection reports them |
190
190
 
191
191
  ### The targeted-walk listeners
192
192
 
@@ -214,8 +214,13 @@ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file
214
214
  | MailboxRoutingListener | Action Mailbox `routing` and processing callbacks |
215
215
  | ModelReferenceListener | Model constants used in controllers: `Post.find`, `params.require(:post)`, ivar writes |
216
216
  | VariantCallListener | `variant` calls (ChainedCallListener with `:variant` preset) |
217
+ | ProcLiteralListener | Proc literals: line, source, assigned constant |
217
218
  | MethodCallListener | Call sites by name or pattern anywhere in a file, inside a `def`, a lambda or a block included, with arguments, options, receiver and line. Used by the Turbo introspector for broadcast calls and by `ActionFilters` for the skip macros |
218
219
 
220
+ `GenericMacroListener.new(*names, block_source: [:name])` adds `block`, the
221
+ one-line source of the block those macros are given. ProcLiteralListener is
222
+ what the job introspector reads a `queue_as` Proc with.
223
+
219
224
  ### Adding a listener
220
225
 
221
226
  1. Subclass `BaseListener` in `lib/rails_ai_context/introspectors/listeners/`. Use its helpers rather than re-reading nodes: `extract_symbol_args`, `extract_keyword_options`, `extract_arg_values` (source-slice fallback for expressions like `2.hours`), `extract_keyword_sources`, `extract_keyword_nodes`, `keyword_hash`, `constant_path_string`.
data/docs/SECURITY.md CHANGED
@@ -66,10 +66,23 @@ After validation, the query runs inside a transaction:
66
66
  |:---------|:----------|
67
67
  | PostgreSQL | `SET TRANSACTION READ ONLY` + `SET LOCAL statement_timeout` |
68
68
  | MySQL | `SET TRANSACTION READ ONLY` + `MAX_EXECUTION_TIME` hint |
69
- | SQLite | `PRAGMA query_only = ON` + progress handler for timeout |
69
+ | SQLite | A read-only connection in a child process, killed at timeout |
70
70
 
71
71
  All queries execute inside a transaction, then rollback (even if they could write, they can't).
72
72
 
73
+ A SQLite query runs in-process under `PRAGMA query_only = ON`, with no time
74
+ limit, for an in-memory database, on a platform without `fork`, or when it
75
+ needs a function, virtual-table module, collation or encryption key only the
76
+ app's own connection has. The table and EXPLAIN answers then say so; CSV output
77
+ stays plain data.
78
+
79
+ On sqlite3 1.x a query fails at its timeout, with the statement-timeout error,
80
+ when another connection in the same process held an exclusive lock on a
81
+ rollback-journal database as it started (`BEGIN EXCLUSIVE`): the child
82
+ inherits that lock record and it never clears. A lock held
83
+ by another process is waited on as usual, and an open write transaction or any
84
+ lock in WAL mode does not block it.
85
+
73
86
  ### Layer 3 - Row limit
74
87
 
75
88
  - Default: 100 rows
@@ -90,6 +103,9 @@ If one of your own columns merely looks sensitive (an `oauth_applications.secret
90
103
  config.query_allowed_columns = %w[secret]
91
104
  ```
92
105
 
106
+ The exemption covers the results too: an allowed name comes back unredacted. A
107
+ column declared with `encrypts` stays `[FILTERED]` either way.
108
+
93
109
  ### Environment guard
94
110
 
95
111
  > [!WARNING]
@@ -115,6 +131,12 @@ config/mongoid.yml config/redis.yml
115
131
  .ssh/* .aws/credentials .aws/config .netrc .pgpass .my.cnf
116
132
  ```
117
133
 
134
+ A placeholder whose name ends in `.example`, `.sample`, `.template` or
135
+ `.dist` (`.env.example`) is committed to be read, so a basename glob such as
136
+ `.env.*` does not block it. A pattern that names it exactly, with no glob
137
+ characters, still does, and so does a path pattern (one with a `/`, such as
138
+ `.ssh/*`), which covers everything under it.
139
+
118
140
  ### AI context file exclusions
119
141
 
120
142
  Search also excludes generated AI context files to prevent circular references:
data/docs/TOOLS.md CHANGED
@@ -179,8 +179,17 @@ Concerns as `[UNAVAILABLE]` rather than dropped.
179
179
  Callbacks grouped by type, in Rails event order, with source code. The list
180
180
  covers concern-declared callbacks too, with the body read from the concern
181
181
  file, and the "From Concerns" section says which concern declared each one.
182
- Within one type the order is declaration order (the model file first, then
183
- its concerns), not the order Rails registered them in.
182
+ Within one type the order is the order Rails runs them: base classes first, a
183
+ concern's callbacks where its `include` line stands, then the model's own, and
184
+ a `before_` or `around_` callback with `prepend: true` first. `after_commit`
185
+ and `after_rollback` are last declared first unless the app turns on
186
+ `run_after_transaction_callbacks_in_order_defined` (`load_defaults 7.1`). A
187
+ method declared again for one type shows once, with the later declaration's
188
+ conditions, as Rails keeps it. The order is read from source in both tiers,
189
+ and dynamic dispatch is not evaluated: a callback registered through
190
+ `send(:before_save, ...)`, through a method called with `send` or defined with
191
+ `define_singleton_method`, or through a module included in `class << self` is
192
+ not read, so the list can miss it or show the definition it replaces.
184
193
 
185
194
  | Parameter | Type | Default | Description |
186
195
  |:----------|:-----|:--------|:------------|
@@ -401,11 +401,10 @@ module RailsAiContext
401
401
  # A module whose file is not found is taken for a concern.
402
402
  def runs_once?(name, root, within)
403
403
  root ||= default_root
404
- path = root && ConcernPaths.find_file(root.to_s, name, prefer: "controller", within: within.to_s)
405
- source = path && SafeFile.read(path)
404
+ source = root && ConcernPaths.module_source(root.to_s, name, prefer: "controller", within: within.to_s)
406
405
  return true unless source
407
406
 
408
- RunCache.fetch([ :concern_module, path ]) do
407
+ RunCache.fetch([ :concern_module, source ]) do
409
408
  Introspectors::SourceIntrospector.walk_source(source, { mixins: Introspectors::Listeners::MixinsListener })[:mixins]
410
409
  .any? { |mixin| mixin[:macro] == :extend && mixin[:name] == "ActiveSupport::Concern" }
411
410
  end
@@ -6,6 +6,8 @@ module RailsAiContext
6
6
  module BaseMixins
7
7
  # `path` is nil when the declaring file is not the app's (a gem's module).
8
8
  Mixin = Struct.new(:name, :path, :macro)
9
+ # A base body's statements, the base it runs in, and its file's place in load order (sorted, as Rails loads initializers).
10
+ Body = Struct.new(:statements, :target, :order)
9
11
 
10
12
  TARGETS = %w[ActiveRecord::Base ApplicationRecord].freeze
11
13
  HOOK = :active_record
@@ -21,12 +23,22 @@ module RailsAiContext
21
23
  RunCache.fetch([ :base_mixins, root ]) { discover(root) }
22
24
  end
23
25
 
24
- def discover(root)
25
- scanned_files(root).flat_map do |file|
26
- source = SafeFile.read(file)
27
- next [] unless source && MARKERS.any? { |marker| source.include?(marker) }
26
+ # Each model base body the app writes outside app/models, where a `def self.x` or
27
+ # `class << self` defines a class method every model under that base has.
28
+ # @return [Array<Body>]
29
+ def bodies(root)
30
+ root = File.expand_path(root.to_s)
31
+ RunCache.fetch([ :base_bodies, root ]) do
32
+ marked(root).each_with_index.flat_map do |(_, tree), order|
33
+ base_bodies(tree).map { |statements, target, _| Body.new(statements, target, order) }
34
+ end
35
+ end
36
+ rescue StandardError => e
37
+ RailsAiContext.debug_fail(e, [], label: "BaseMixins.bodies")
38
+ end
28
39
 
29
- tree = AstCache.parse(file).value
40
+ def discover(root)
41
+ marked(root).flat_map do |file, tree|
30
42
  mixed_in(tree).map { |macro, name| Mixin.new(name, declaring_file(root, name, file), macro) }
31
43
  end.uniq(&:name)
32
44
  rescue StandardError => e
@@ -34,6 +46,15 @@ module RailsAiContext
34
46
  end
35
47
  private_class_method :discover
36
48
 
49
+ # [file, tree] for each scanned file that names a model base.
50
+ def marked(root)
51
+ scanned_files(root).filter_map do |file|
52
+ source = SafeFile.read(file)
53
+ [ file, AstCache.parse(file).value ] if source && MARKERS.any? { |marker| source.include?(marker) }
54
+ end
55
+ end
56
+ private_class_method :marked
57
+
37
58
  # Everywhere the app configures itself but app/models: a model file's includes are
38
59
  # its own, and the model walk reads that file already.
39
60
  def scanned_files(root)
@@ -50,25 +71,31 @@ module RailsAiContext
50
71
  private_class_method :scanned_files
51
72
 
52
73
  # [macro, constant] for each module the tree mixes into a model base:
53
- # sent to it, run in its `on_load` hook, or written in its reopened body.
74
+ # sent to it, run in its `on_load` hook, or written in its own body.
54
75
  def mixed_in(tree)
55
76
  Introspectors::AstWalk.each(tree).flat_map do |node|
56
77
  next [] unless node.is_a?(Prism::CallNode)
57
78
  next hook_mixins(node) if hook?(node)
58
79
 
59
80
  target?(node.receiver) ? mixin_calls(node) : []
60
- end + reopened_mixins(tree)
81
+ end + base_bodies(tree).reject(&:last).flat_map(&:first).flat_map { |call| call.is_a?(Prism::CallNode) && call.receiver.nil? ? mixin_calls(call) : [] }
61
82
  end
62
83
 
63
- # `class ActiveRecord::Base; include X; end`, however the namespace is written.
64
- def reopened_mixins(tree)
65
- Introspectors::DeclaredConstant.constants(tree).flat_map do |name, node|
66
- next [] unless node.is_a?(Prism::ClassNode) && TARGETS.include?(name) && node.body
84
+ # [statements, base, hook?] for each body a base runs as self: `class ActiveRecord::Base` reopened
85
+ # however the namespace is written, `ActiveRecord::Base.class_eval do`, and an `on_load` block.
86
+ def base_bodies(tree)
87
+ reopened = Introspectors::DeclaredConstant.constants(tree).filter_map do |name, node|
88
+ [ node.body, name, false ] if node.is_a?(Prism::ClassNode) && TARGETS.include?(name)
89
+ end
90
+ evaluated = Introspectors::AstWalk.each(tree).filter_map do |node|
91
+ next unless node.is_a?(Prism::CallNode) && node.block
92
+ next [ node.block.body, TARGETS.first, true ] if hook?(node)
67
93
 
68
- node.body.body.flat_map { |call| call.is_a?(Prism::CallNode) && call.receiver.nil? ? mixin_calls(call) : [] }
94
+ [ node.block.body, node.receiver.slice.delete_prefix("::"), false ] if %i[class_eval class_exec].include?(node.name) && target?(node.receiver)
69
95
  end
96
+ (reopened + evaluated).filter_map { |body, *rest| [ body.body, *rest ] if body.is_a?(Prism::StatementsNode) }
70
97
  end
71
- private_class_method :reopened_mixins
98
+ private_class_method :base_bodies
72
99
 
73
100
  # Inside `on_load(:active_record) { ... }` the block runs in the base.
74
101
  def hook_mixins(node)
@@ -109,11 +136,12 @@ module RailsAiContext
109
136
  private_class_method :constants
110
137
 
111
138
  # Past Zeitwerk's path, a plugin's module sits in a lib file its init.rb
112
- # requires, under a name its path does not spell.
139
+ # requires, under a name its path does not spell; a namespace stub is not it.
113
140
  def declaring_file(root, name, file)
114
- ConcernPaths.find_file(root, name) ||
141
+ ConcernPaths.find_file(root, name, outer: false) ||
115
142
  ([ file ] + required_files(root, file, 2)).find do |candidate|
116
- Introspectors::DeclaredConstant.module_node(AstCache.parse(candidate).value, name)
143
+ node = Introspectors::DeclaredConstant.module_node(AstCache.parse(candidate).value, name)
144
+ node && !Introspectors::DeclaredConstant.stub?(node)
117
145
  end
118
146
  rescue StandardError => e
119
147
  RailsAiContext.debug_fail(e, nil, label: "BaseMixins.declaring_file #{name}")