@@ -5,6 +5,192 @@ All notable changes to this project will be documented in this file.
55The format is based on [ Keep a Changelog] ( https://keepachangelog.com/en/1.1.0/ ) ,
66and this project adheres to [ Semantic Versioning] ( https://semver.org/spec/v2.0.0.html ) .
77
8+ ## [ 5.24.0] - 2026-08-17
9+
10+ ### Fixed
11+
12+ Defects found by a QA round against GitLab, OpenProject, Canvas LMS,
13+ Discourse and Mastodon. Every one of them exits 0.
14+
15+ - ** The commands that read the app degrade the way ` tool ` does.** ` context ` ,
16+ ` inspect ` , ` facts ` , ` preset ` , ` watch ` and ` init ` called a bare boot guard,
17+ so on a repo you have just cloned - the case an agent most needs a
18+ ` CLAUDE.md ` for, and the case least likely to boot - every tool answered
19+ and the command that writes the files exited 1 having written nothing.
20+ ` init ` was worse: it writes its config files first, so a boot failure left
21+ the app half set up and called that a failure. They allow the static tier
22+ now and each takes ` --no-boot ` ; ` doctor ` still fails, because diagnosing
23+ the boot is its job. Writing under ` --no-boot ` then exposed its own bug -
24+ the context writer asked ` Rails.application ` for the output directory,
25+ which raises ` NameError ` on the path where Rails is never loaded at all.
26+ - ** ` tool --list ` reads the app's config when the app cannot boot.** Boot is
27+ what normally loads ` .rails-ai-context.yml ` , so the listing fell back to
28+ the gem's defaults: on Mastodon it advertised 45 tools while the MCP server
29+ offered 43 and the CLI itself answered ` Unknown tool 'query' ` for one it
30+ had just listed.
31+ - ** ` search_extensions ` is documented as the fallback's list, which is what
32+ it is.** Making ripgrep honour it did make the two backends agree, and it
33+ cost the reach that makes the tool useful: on Mastodon ` gem 'devise' ` went
34+ from 5 results to none, because a Gemfile carries no listed extension - and
35+ the same for a Rakefile, a ` .md ` , a ` .sql ` . The docs, the attr comment and
36+ the line the install generator writes now say plainly that the list is the
37+ Ruby fallback's and that ripgrep searches every file.
38+ - ** A table the replay cannot name is not reported.** Canvas has a migration
39+ that calls ` create_table table_name do |t| ` with a local computed at run
40+ time; the replay kept the entry under a nil key, and the first serializer
41+ to sort the table names took the whole context run down. Reachable only
42+ once ` context ` could enter the static tier at all.
43+ - ** Coverage says nothing when there is nothing to measure against.** With a
44+ ` default_locale ` the app configures but ships no file for, every locale
45+ scored zero and all of them were named as untranslated.
46+ - ** A mixin in a nested concerns directory is not a model.** The skip only saw
47+ the top-level ` concerns/ ` that Rails autoloads, so OpenProject's
48+ ` app/models/queries/operators/concerns ` contributed four mixins to a model
49+ count of 978 where the app has 974. A nested ` concerns/ ` is an ordinary
50+ namespace, though, so the directory name alone does not decide it: a file
51+ there that declares a class is a model like any other.
52+ - ** ` docs/COMPATIBILITY.md ` describes the static tier the gem actually has.**
53+ It said 6 introspectors answer without a booted app and "the other 34 have
54+ no static path", naming eight examples - all of which answer. The real
55+ split is 23 files-only, 9 alternate-source and 8 runtime-only: 32 of 40.
56+ A guard spec derives all three lists from ` INTROSPECTOR_MAP ` .
57+
58+ - ** A controller is named by the constant its source declares.** Zeitwerk
59+ resolves a path through the app's own inflector, which the static tier
60+ never loads, so camelizing the path invented ` Activitypub:: ` for the 12
61+ controllers Mastodon declares as ` ActivityPub:: ` and ` Oauth:: ` for the 4
62+ it declares as ` OAuth:: ` - names that appear nowhere in an app that
63+ registers those acronyms, and a ` NameError ` for anyone who uses one. The
64+ path stays the answer where the source does not carry the whole name
65+ (` application_cable/channel.rb ` ) and where the declared name does not name
66+ that file, since Prism recovers a syntax error into a partial tree.
67+ - ** An interceptor is told from a mailer by its framework hook.**
68+ ` app/mailers ` is also where ActionMailer interceptors live, and every ` .rb `
69+ under it counted: OpenProject's ` Interceptors::DefaultHeaders ` arrived as a
70+ mailer with ` delivering_email ` - a hook the framework calls - listed as an
71+ email an agent could send, 2 of its 11 entries. What an interceptor has and
72+ a mailer does not is one of ` delivering_email ` , ` previewing_email ` or
73+ ` delivered_email ` ; requiring a class instead would have dropped GitLab's 20
74+ ` Emails::* ` modules, which hold every notification it sends.
75+ - ** Migration replay reads what a migration does, not what it undoes.** A
76+ ` def down ` says how to reverse the change, so replaying it alongside ` up `
77+ cancelled the migration out. On a migrations-only app the ordinary
78+ up-creates/down-drops pair erased two tables OpenProject really has (35
79+ to 37), and the reverse pair would have invented one it dropped. The same
80+ now holds for the block spellings, ` reversible { |dir| dir.down { ... } } `
81+ and ` revert ` , and for the ` t.timestamps ` inside them - those were found by
82+ a separate walk over the whole file, so a down body's timestamps landed on
83+ the last table created on the way up.
84+ - ** i18n coverage groups the locales below its own rounding floor instead of
85+ listing them.** A language-name lookup table under ` config/locales `
86+ contributes a top-level key per language, and Rails does load each as an
87+ available locale. Scoring them produced a row each saying every key was
88+ missing: on Discourse, 138 of 186 coverage rows described a translation
89+ effort nobody had started. Those 138 are now summarised in one line, with a
90+ shared key count stated once. The rows still exist - asking for one by name
91+ answers with its numbers, so a translation genuinely started below the floor
92+ is not written off as untranslated. The available list still matches a
93+ booted Rails, and a locale's keys are read from its own file and any shared
94+ file together, so one that lives outside the naming convention is scored.
95+ - ** A recorded tool selection no longer switches off ` .ai-context.json ` .**
96+ The install generator always records one, and every later `rails
97+ ai: context ` passed that list to the serializer. The machine artifact is
98+ not an AI tool and no install menu offers it, so it silently stopped being
99+ written on the one install path the docs describe - while the generator
100+ went on adding it to ` .gitignore ` .
101+ - ** ` skip_tools ` accepts the symbol spelling its neighbours use.** Every
102+ other key in the generated initializer takes symbols, and ` %i[] ` here
103+ skipped nothing at all: both readers compare against a tool name, which is
104+ a String.
105+
106+ - ** A controller carries the file it was read from.** Reading the name from
107+ the source fixed the name and broke every path derived from it:
108+ ` ActivityPub::CollectionsController ` .underscore is ` activity_pub/... ` and
109+ the file is under ` activitypub/ ` , so ` rails_get_context ` answered "Could
110+ not extract source code" for a file that exists and asked the routes for a
111+ controller key Rails does not use. Both tiers emit ` file: ` - the booted one
112+ asks Ruby where the class was defined - and the consumers read it through
113+ ` Payload ` .
114+ - ** The locale files are indexed once, not once per locale.** Asking every
115+ file about every locale is O(locales x files): on Discourse, 187 over 108,
116+ it took the i18n answer from 4.6 seconds to four and a half minutes. Each
117+ entry also records the locales it serves, so asking for one by name finds
118+ its files even when the filename spells the gem rather than the locale.
119+
120+ - ** Every consumer that turns a controller name back into a path reads the
121+ file it was read from.** Carrying ` file: ` fixed six of them and left five
122+ answering a confident negative, which is the answer an agent acts on
123+ without checking. On Mastodon's 18 inflected controllers: ` rails_test_info `
124+ reported 16 specs that exist as missing, ` rails_get_routes ` reported 4
125+ routes that exist as absent, ` rails_get_view ` 2 views, and
126+ ` rails_generate_test ` wrote a spec whose body was ` skip "no routes found" `
127+ for a controller with a route. ` rails_analyze_feature ` listed them as
128+ untested. ` Payload.controller_route_key ` is the one derivation now, and a
129+ spec drives all five tools with an inflected name.
130+ - ** The install path's files require their own stdlib.** ` init ` loads six
131+ files before Rails and before the entry file, on purpose - so the entry
132+ file's ` require "set" ` never runs for them. ` legacy_cleanup ` 's ` [...].to_set `
133+ raised ` NoMethodError ` on Ruby 3.1, where ` Set ` is not autoloaded, and 3.2+
134+ hid it: only the CI matrix ever saw it.
135+ - ** ` rails_runtime_info ` reports cache numbers, not the cache object.** The
136+ MemoryStore branch passed ` cache.inspect ` straight through, so the answer
137+ carried ` #<ActiveSupport::Cache::MemoryStore entries=0, size=0, options={...}> `
138+ - the shape this gem has shipped as a defect before. Entries and size are
139+ now facts, and a store that answers ` #stats ` gets its hash rendered as pairs
140+ rather than a Ruby literal.
141+ - ** A model's table comes from its file, not from its name.** Naming a model by
142+ the constant its source declares made ` underscore ` stop being the way back:
143+ ` OAuthClientConfig ` underscores to ` o_auth_client_config ` , and the file is
144+ ` oauth_client_config.rb ` . ` rails_model_details ` reported the table as
145+ ` o_auth_client_configs ` , which no app has, so the columns section vanished
146+ and the schema hint pointed at nothing - on Canvas that hit
147+ ` OAuthClientConfig ` , ` OAuthRequest ` , ` AuthenticationProvider::OAuth ` and
148+ ` OAuth2 ` , on OpenProject ` OAuthClient ` and ` OAuthClientToken ` . The file's own
149+ name already carries the inflection, because Zeitwerk resolved the constant
150+ from it. ` rails_generate_test ` , ` rails_get_callbacks ` , ` rails_test_info ` and
151+ the five ` rails_validate ` checks that key models by path read it too.
152+ - ** An abstract base class is not a model in the static tier either.** A
153+ booted Rails rejects ` abstract_class? ` , and a namespaced base class is one:
154+ GitLab's ` Ci::ApplicationRecord ` , ` PackageMetadata::ApplicationRecord ` and
155+ ` SecApplicationRecord ` were counted, so the same app answered 895 models
156+ without a boot and 888 with one. OpenProject: 974 against 971.
157+ - ** A model carries its file too, and is named by the constant its source
158+ declares.** ` rails_model_details ` rebuilt ` app/models/<underscored>.rb ` from
159+ the name in four places, so for a model in a pack or an engine the custom
160+ validations, the source-defined methods, the method signatures and the class
161+ structure all went quietly missing from an answer that otherwise looked
162+ complete. The static tier also camelized the path into the name, so an app
163+ registering an inflection got a constant it does not have - and one the
164+ booted tier, which reads the real class, would never agree with.
165+ - ** ` rails_validate_semantics ` checks the views under an inflected directory.**
166+ A view directory names the route key, so camelizing ` app/views/oauth/ ` back
167+ gave a constant the app never declares and the undefined-ivar check stopped
168+ running for that whole tree without saying so.
169+ - ** A test-gap claim is not made from a scan that stopped early.**
170+ ` rails_analyze_feature ` scans the first 500 files per glob; Discourse has
171+ 1,672 specs, so the scan never reached the ones covering the feature and
172+ every controller in it was reported as having no test. The Tests section
173+ disclosed the cap, the gaps section asserted through it.
174+ - ** "Global before_actions" in the generated files means global.** The scan
175+ matched ` skip_before_action ` too, and ignored ` only: ` / ` except: ` / ` if: ` .
176+ Mastodon's ` CLAUDE.md ` listed five, of which three were false - including
177+ ` verify_authenticity_token ` , which that controller skips.
178+
179+ ### Added
180+
181+ - ** ` --no-boot ` on ` context ` , ` inspect ` , ` facts ` , ` preset ` , ` watch ` and
182+ ` init ` .** The static tier was reachable from ` tool ` and ` serve ` only.
183+ - ** ` --no-boot ` reads a repo that has source but no ` config/ ` .** An engine
184+ keeps its dummy app under ` spec/dummy ` , so the guard against describing an
185+ empty directory has to measure source, not boot files.
186+
187+ ### Changed
188+
189+ - ** The generated initializer has one indent from top to bottom.** The AI
190+ Tools section was written at the configure body's indent and every section
191+ after it flush, so the file an app commits changed indent two lines in and
192+ stayed there.
193+
8194## [ 5.23.0] - 2026-08-16
9195
10196### Fixed
0 commit comments