rails-ai-context 5.35.2 → 5.36.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +177 -1
- data/CONTRIBUTING.md +1 -0
- data/docs/COMPATIBILITY.md +10 -1
- data/docs/SECURITY.md +11 -9
- data/docs/TOOLS.md +11 -10
- data/docs/TROUBLESHOOTING.md +1 -1
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +17 -2
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +6 -2
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +41 -6
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +9 -3
- data/lib/rails_ai_context/database_family.rb +27 -5
- data/lib/rails_ai_context/introspector.rb +12 -0
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -2
- data/lib/rails_ai_context/introspectors/job_introspector.rb +54 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +13 -11
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +12 -1
- data/lib/rails_ai_context/payload.rb +2 -3
- data/lib/rails_ai_context/secret_columns.rb +59 -37
- data/lib/rails_ai_context/sql_gate.rb +115 -24
- data/lib/rails_ai_context/tools/get_conventions.rb +10 -5
- data/lib/rails_ai_context/tools/get_schema.rb +35 -11
- data/lib/rails_ai_context/tools/query.rb +12 -2
- data/lib/rails_ai_context/tools/runtime_info.rb +15 -10
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +1 -23
- data/.rspec +0 -3
- data/.rubocop.yml +0 -16
- data/.ruby-version +0 -1
- data/AGENTS.md +0 -15
- data/CLAUDE.md +0 -3
- data/CODE_OF_CONDUCT.md +0 -44
- data/CONTEXT.md +0 -242
- data/Rakefile +0 -80
- data/docs/RELEASING.md +0 -72
- data/docs/_config.yml +0 -15
- data/docs/_includes/head-custom.html +0 -16
- data/docs/adr/0001-own-listener-registration.md +0 -9
- data/docs/adr/0002-static-tier-declared-not-detected.md +0 -5
- data/docs/adr/0003-shared-tool-cache-semantics.md +0 -31
- data/docs/adr/0004-install-e2e-stays-example-rich.md +0 -28
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +0 -38
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +0 -26
- data/docs/adr/0010-one-config-tool-with-sections.md +0 -32
- data/docs/agents/domain.md +0 -38
- data/docs/agents/issue-tracker.md +0 -45
- data/docs/agents/triage-labels.md +0 -15
- data/server.json +0 -21
data/CONTEXT.md
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
# Context
|
|
2
|
-
|
|
3
|
-
Terms this project uses in a narrower sense than everyday English. One entry per term that has caused a naming collision or an ambiguous read.
|
|
4
|
-
|
|
5
|
-
## Environment
|
|
6
|
-
|
|
7
|
-
Overloaded. Always qualify it; never use "environment" bare in a tool name, an introspector name, or a config key.
|
|
8
|
-
|
|
9
|
-
**Process environment** - the environment variables a running app sees, plus the places they are declared (`ENV[]` call sites, `.env.example`, Dockerfile, credentials keys). Served by `rails_get_config(section:"env")`: `EnvIntrospector` reads the variables and the `ENV[]` call sites, and the tool reads the other places itself.
|
|
10
|
-
|
|
11
|
-
**Environment config** - what `config/environments/*.rb` declares per environment: the assigned `config.*` keys and the values of the notable toggles. Served by `rails_get_config(section:"env_config")`, read by `EnvConfigIntrospector`.
|
|
12
|
-
|
|
13
|
-
**Environment** (unqualified, as data) - a single named Rails environment: development, production, staging. This is the only sense in which the bare word is allowed, and only as a value, never as a name. The `:environments` payload key means "the list of these", which is why it kept its name when the introspector was renamed.
|
|
14
|
-
|
|
15
|
-
## AI tool
|
|
16
|
-
|
|
17
|
-
The assistant a user points at their app: claude, cursor, copilot, opencode, codex. What the gem generates for one is its **context files** (`CLAUDE.md`, `.cursor/rules/*.mdc`, and so on).
|
|
18
|
-
|
|
19
|
-
Avoid "format" for either sense - claude is not a file format, and the word collides with real formatting elsewhere in the code. The public config key `ai_tools` already uses the canonical term.
|
|
20
|
-
|
|
21
|
-
`Install::AiTool` is the one table of what each means: name, context files, MCP config shape, legacy leftovers. `Install::SelectionRecord` owns which ones the user picked - written to YAML always, and to the initializer line when the caller asks for it and the line is in a shape it can rewrite (an ordinary `ai:context` run refreshes the YAML only), read initializer-first because that is the file a user hand-edits.
|
|
22
|
-
|
|
23
|
-
## Path
|
|
24
|
-
|
|
25
|
-
Two senses, one per module, and neither is bare "path" in a name.
|
|
26
|
-
|
|
27
|
-
**Where code lives** - `PathResolver` answers which directories a kind of app code can occupy for a given root: conventional layout, packwerk packs, in-repo engines, configured extras.
|
|
28
|
-
|
|
29
|
-
**How a path is written down** - `PortablePath` rewrites one so it means the same thing on another machine, because what it touches ends up in `.ai-context.json` and the app commits that file. App paths go app-relative, gem paths keep the gem and version and drop the install prefix. Where a reader would otherwise resolve the result against the app root, `relativize_marked` prefixes a gem path with `gem:`. "Relativize" always means this.
|
|
30
|
-
|
|
31
|
-
## Safe path
|
|
32
|
-
|
|
33
|
-
A caller-supplied path resolved once, through `SafePath`, before anything reads it. Its refusal order is the contract, not an implementation detail: traversal, sensitive name, realpath, containment (the directory asked about, or a **linked-in** directory on the way to it), sensitive realpath, file, size. A caller that reorders those checks, or repeats one of them itself, gets a different answer on a symlink or a dotfile than every other tool does, which is the divergence the module exists to end. `BaseTool.safe_glob` is the globbed-path form: realpath, containment and the sensitive-realpath check on each path a pattern yields, with the size cap left to the read that follows.
|
|
34
|
-
|
|
35
|
-
Not the same as a path that merely looks harmless, and not a caller's own containment check - "the tool guards this parameter" means it hands the parameter to `SafePath` and renders whatever refusal comes back.
|
|
36
|
-
|
|
37
|
-
## Carried path
|
|
38
|
-
|
|
39
|
-
A path the payload already holds because the gem's own walk found it - a controller's or a model's `file:`. A controller's is re-read with `SafeFile.read(File.join(root, relative))`, which checks that it is a regular file under the size cap and nothing else; a model's goes through `PortablePath.resolve` first, since it can be a `gem:` path. The reason is the walk: it already decided the file is the app's (see **Linked out**), keeping the spelling the app uses when the realpath falls outside the root, and a model's file can be a gem's, which `SafePath` refuses as outside the root - the source would come back nil and a section silently empty.
|
|
40
|
-
|
|
41
|
-
Not an exception to the **safe path** rule, the other side of it: a caller-supplied path is untrusted and goes through `SafePath`; a carried path was produced by this gem and only needs the cap.
|
|
42
|
-
|
|
43
|
-
## Linked out
|
|
44
|
-
|
|
45
|
-
A file the app spells inside its root, or inside the engine its test/dummy runs in, that a symlink takes somewhere neither of those holds nor a **linked-in** directory does: the file is a link, or sits under one. `PathResolver.linked_out?` answers it. A walk that reads what it finds drops such a file before listing it (`FileWalk`, `SourceScan`, `ViewFile.glob` and the introspectors' own globs), and so does a read of the file Ruby says a loaded class came from, so a **carried path** is never one. A link that resolves nowhere is not linked out, since nothing can be read through it, and a walk drops it as no file.
|
|
46
|
-
|
|
47
|
-
Not the **safe path** check, which refuses a path a caller supplied: this one decides which of the files the gem found itself are the app's. Booted, a class Rails loaded through such a link is still described by reflection; only its file goes unread.
|
|
48
|
-
|
|
49
|
-
## Linked-in directory
|
|
50
|
-
|
|
51
|
-
A directory link inside the app that leads somewhere in the git work tree holding the app (`SafePath.link_bound`): a monorepo package linked into `packs/` or `app/javascript/controllers`. Its files are the app's, named the way the app spells them (`packs/billing/app/models/invoice.rb`), because Zeitwerk, the view resolver and the bundlers follow it; a file under it that links further out, past the app and the directory, is **linked out**. Outside any repository the bound is the app root, so nothing outside it is linked in. `SafePath.linked_in` finds the directory on a path's spelling, and `PathResolver.enter_link?` decides whether a walk enters one: never one back up over the directory being walked. The walks that stand for Rails' own globs - initializers, locales, migrations, seeds, rake tasks, test files - enter no linked directory, as those globs do not.
|
|
52
|
-
|
|
53
|
-
## Source scan
|
|
54
|
-
|
|
55
|
-
The one walk over a kind of app source, across every directory `PathResolver` resolves for it: conventional layout, packs, in-repo engines. `SourceScan.paths` stats only, `each` reads the source on top of it, and `classes` names each file by its declared constant. An introspector that globs `app/<kind>` itself is the mistake this entry exists to name: it misses every pack and engine, and it names files by their path rather than by what they declare.
|
|
56
|
-
|
|
57
|
-
Distinct from the **eager load** of the booted tier (`EagerLoad`), which makes constants exist rather than reading files, and from a **targeted walk** - one named file handed to `SourceIntrospector` with the listeners that file needs.
|
|
58
|
-
|
|
59
|
-
## Declared constant
|
|
60
|
-
|
|
61
|
-
What a source file calls its own class, as opposed to the **path name** - the constant its path camelizes to. The two differ wherever the app registers an inflection, because Zeitwerk resolves a path through the app's own inflector and the static tier has never loaded it: `app/controllers/activitypub/` is `ActivityPub` in Mastodon, and `Oauth` is a constant nothing defines. `DeclaredConstant` reads the class the source declares, and since an inflection only ever changes case, the declaration that names a file is the one equal to the path name ignoring case. Anything else - a second class in the file, a nested error class, a tree Prism recovered from a syntax error - is not this file's class, and there the path name stays the answer: it is the only thing carrying the namespace when the source does not.
|
|
62
|
-
|
|
63
|
-
Distinct from the **Static tier** sense of "declared": that one is about declaring a tier's capability up front rather than detecting it at runtime. This one is about a constant's spelling.
|
|
64
|
-
|
|
65
|
-
The other half of the same problem is the reverse trip. Once a name is the declared one, no consumer can rebuild the path from it - and a pack or an in-repo engine breaks that derivation too, inflection or not. So controllers and models carry `file:` from whichever tier found them, and consumers read it through `Payload` (`controller_file`, `controller_route_key`, `controller_for_route_key`, `model_file`). The rule: a tool that needs a path for a name reads it, never underscores it.
|
|
66
|
-
|
|
67
|
-
## Table name
|
|
68
|
-
|
|
69
|
-
The table a model reads. Rails builds it as `table_name_prefix` + the demodulized class name, pluralized unless `pluralize_table_names` is off, + `table_name_suffix`, unless the class assigns one itself or is an STI child, which reads its parent's. The static tier answers the same order from source: an explicit `self.table_name =`, else the table of the model it inherits from, else the prefix and suffix the enclosing modules declare around the file's own basename. That basename is pluralized unless the class, then its superclass chain, then the app sets `pluralize_table_names = false`. A class on a parent the app does not define (`Tag < ActsAsTaggableOn::Tag`) reads the parent's table, which the static tier takes from the dumps: the one table the primary's schema holds among the parent's names and the class's own, else the one another database's dump holds. The app-wide affixes and pluralize setting come from `ActiveRecordSettings`, which reads config/application.rb, the environment file, the initializers and `on_load(:active_record)` blocks. Never the underscored constant as an answer - that turns `Admin::ActionLog` into `admin/action_logs` and `OAuthClientConfig` into `o_auth_client_configs`, neither of which is a table; a gem parent's underscored name is only a candidate the schema must hold.
|
|
70
|
-
|
|
71
|
-
`TableName` reads the five declarations of one class body (`table_name`, `table_name_prefix`, `table_name_suffix`, `pluralize_table_names` and `primary_key`) and holds the two derivations; walking the namespace and the superclass chain belongs to the caller, because only it has the other files. A reader that starts from a model name instead of a file asks `TableName.for_model_name`, which takes the table the model tier already recorded before falling back to the convention. A module prefix declared outside the model directories is still invisible, an engine's in `lib/**/engine.rb` aside, so the answer stays [STATIC].
|
|
72
|
-
|
|
73
|
-
## Static tier
|
|
74
|
-
|
|
75
|
-
The mode where the app did not boot, or `--no-boot` was passed. What an introspector answers here is what it declared, never what a runtime check happened to detect. Every introspector in `INTROSPECTOR_MAP` extends `StaticTier` and names one of three kinds:
|
|
76
|
-
|
|
77
|
-
**files-only** - `call` runs unchanged against the static app handle (`StaticApp`), because it only ever read files. Most of the map.
|
|
78
|
-
|
|
79
|
-
**alternate-source** - `static_call` answers from a different source than `call` does: `db/schema.rb` instead of the connection, `config/routes.rb` instead of the route set.
|
|
80
|
-
|
|
81
|
-
**runtime-only** - needs `app.config`, `app.routes` or a live database, and refuses honestly.
|
|
82
|
-
|
|
83
|
-
An undeclared introspector fails the suite, and every files-only declaration is proven against `spec/fixtures/static_app` with nothing booted. Declaring files-only while defining `static_call` (or the reverse) is also a spec failure.
|
|
84
|
-
|
|
85
|
-
The tier also caps what the records inside an answer may claim: no record can claim more than the tier carrying it, so an association or a scope the AST resolved with certainty reads `[STATIC]` inside a static entry rather than the `[VERIFIED]` the walk would give it on its own. One that the parser could not resolve keeps its lower `[INFERRED]`. A booted entry carries no tier mark, so its records keep theirs.
|
|
86
|
-
|
|
87
|
-
## Concern
|
|
88
|
-
|
|
89
|
-
Three senses inside the gem, and the payload one is wider than either everyday Rails reading.
|
|
90
|
-
|
|
91
|
-
**Concern (as payload)** - a module in a class's ancestor chain, minus framework noise. Not only `ActiveSupport::Concern`, and not only files under a concerns directory: anything reached by `include` or `prepend` counts, which is why `extend` and a singleton-class `include` do not. This is what `:concerns` holds in model and controller data and what a "Concerns" section renders. The booted tier reads `ancestors`, so it also sees what the superclass and other concerns pulled in; the static tier's list is the class's own file alone. The macros are a separate question: `ConcernMacros` walks the concern files on both tiers and merges what they declare into the same payload keys, tagged `from_concern:`, so a model whose associations all live in concerns does not answer "0 assoc". `ConcernMembership` decides membership for both tiers; renderers trust the payload rather than re-filtering.
|
|
92
|
-
|
|
93
|
-
**Concerns directory** - `app/*/concerns` as a place, glob-derived the way Rails autoloads it, never a hardcoded list. `ConcernPaths` is the one answer to "where does this app keep its concerns" for every surface that lists or counts them.
|
|
94
|
-
|
|
95
|
-
**Mixin** - the static detection feeding the payload: any `include`, `prepend` or `extend` with a constant argument, as `MixinsListener` reports it. Named mixin because it captures more than concerns; its `ancestor` flag marks the subset that becomes one.
|
|
96
|
-
|
|
97
|
-
## Action
|
|
98
|
-
|
|
99
|
-
The callable interface of a class as this gem reports it: the class's own public instance methods - a class nested in the same file is a separate owner, not part of the interface - minus framework-shaped `_` names, read source-first, with reflection minus the app-owned base as the honest fallback. `ActionResolver` is the one answer; controller and mailer are configurations of it, and a channel's "stream methods" are a narrower selection of the same reading.
|
|
100
|
-
|
|
101
|
-
## Mounted app
|
|
102
|
-
|
|
103
|
-
A Rack app the routing table attaches at a path, engine or not. `mount App =>
|
|
104
|
-
"/path"` is `match("/path", to: App, via: :all, anchor: false)` with a name
|
|
105
|
-
derived, so both spellings build the same endpoint and both are this. The
|
|
106
|
-
payload key is `mounted_engines` for the sections that predate the widening;
|
|
107
|
-
in the booted routes section it holds every controller-less, non-dynamic
|
|
108
|
-
endpoint the route set carries, which is what the count beside it has always
|
|
109
|
-
counted. The engines section, and the static routes section, hold the mounts
|
|
110
|
-
parsed from the route files.
|
|
111
|
-
|
|
112
|
-
Distinct from a **Rails engine**, which is a `Rails::Engine` subclass whether
|
|
113
|
-
or not anything mounts it, and which `rails_get_config(section:"engines")`
|
|
114
|
-
lists separately under its loaded classes. The record key is `engine:` for the
|
|
115
|
-
same reason the section key is `mounted_engines:` - it predates the widening,
|
|
116
|
-
and what it holds is the mounted app's constant, engine or not.
|
|
117
|
-
|
|
118
|
-
## Interaction filter
|
|
119
|
-
|
|
120
|
-
What an ActiveInteraction service declares as its interface, and not the
|
|
121
|
-
**filter chain** above, which is controllers. `Interaction` is the one answer
|
|
122
|
-
for both tools that read it (`rails_get_service_pattern`,
|
|
123
|
-
`rails_generate_test`), because a static answer that differs from the booted
|
|
124
|
-
one is the divergence the module exists to end.
|
|
125
|
-
|
|
126
|
-
**A nested filter is not an input.** `string :title` inside `hash
|
|
127
|
-
:order_params do ... end` is a key of that hash: it never reaches `.filters`,
|
|
128
|
-
and handing it to `.run` as a keyword argument is dropped without a word. So
|
|
129
|
-
nesting is kept on the record rather than flattened, and only the top level is
|
|
130
|
-
an input.
|
|
131
|
-
|
|
132
|
-
**The chain is the class's, not the file's.** An interaction is often a
|
|
133
|
-
subclass of the app's own base interaction rather than of
|
|
134
|
-
`ActiveInteraction::Base`, and its filters are then the parent's first and its
|
|
135
|
-
own after, which is the order `.filters` answers in. Following that chain needs
|
|
136
|
-
the parent's source, which only the caller can find, so it arrives as
|
|
137
|
-
`lookup` - a callable from a constant name to that class's source, and nil to
|
|
138
|
-
stop at the one file.
|
|
139
|
-
|
|
140
|
-
## Filter chain
|
|
141
|
-
|
|
142
|
-
Which filters a controller runs, and which of them a given action runs. `ActionFilters` is the one answer, and every controller surface reads its filter line from there, so no two answers can disagree about what a class inherits or skips. `for_controller` answers about the class; `for` answers about one of its actions. Both return `own`, `inherited` and `skipped`.
|
|
143
|
-
|
|
144
|
-
**A skip is not the absence of a filter.** `skipped` holds unconditional skips only, and those are the ones a surface strikes through. Everything else keeps its place in the chain and carries the reason on the record, because a filter a class skips on some requests is still a filter that runs on the others: `if:`/`unless:` become `skipped_if`/`skipped_unless`, and `only:`/`except:` become `skipped_on`/`skipped_except` in the whole-controller answer.
|
|
145
|
-
|
|
146
|
-
**Ask about one action and the answer is absolute again.** On that action the filter either runs or is struck through, so a constraint that does not cover the action leaves no tail at all. That cuts both ways, and the second direction is the one that is easy to get wrong: a skip whose constraint leaves this action alone is the only evidence the chain runs that filter here at all, when the class that declared it is not in the payload. Such a record is marked as evidence, never as a skip, and a real condition on the same name always outranks it.
|
|
147
|
-
|
|
148
|
-
**Order is the chain order, and a class's own body wins.** After filters run in reverse of it, as Rails does. The chain keeps the order reflection or the source gives it, so a `prepend_before_action` does not fall behind everything the class inherits. One call's callbacks come in argument order with the block last, as Rails adds them. A filter the body declares survives an ancestor's skip of the same name, because Rails re-adds it. Attribution names the ancestor whose body declared the filter, not the nearest one that merely carries it inherited.
|
|
149
|
-
|
|
150
|
-
**A macro inside a method runs when the method is called.** A `skip_before_action` in a concern's `class_methods` (the Rails 8 `allow_unauthenticated_access`) declares nothing until a controller calls the method; at that call it declares what the method body does, with the call's options, wherever on the chain the method is defined. `ControllerFilters` reads it so for both tiers.
|
|
151
|
-
|
|
152
|
-
**A block filter is named by its line.** `before_action { ... }` and `after_action -> { ... }` are callbacks of their own, listed as `block (line N)` and `lambda (line N)` (a lambda says so, as `Proc#lambda?` does, so it reads apart from a block on its line), N being the line the block opens on in the controller's own file; a block in any other file (a concern's class method, an ancestor's body) is `block (line N of app/controllers/concerns/cache_concern.rb)`, as two blocks on one line number in two files are two callbacks. The booted tier lists only a block the app wrote; a framework's or a gem's (`allow_browser`) is left out, as the static tier cannot see it. `skip_forgery_protection` is the `skip_before_action :verify_authenticity_token` Rails defines it as.
|
|
153
|
-
|
|
154
|
-
**What it cannot see is stated rather than guessed.** The walk reads `ApplicationController`'s file by the one name Rails fixes, so its filters are in the chain; a parent the listing does not hold and the app has no file for - a gem-owned base other than Devise's or Doorkeeper's (there the walk resumes at the parent the initializer names), or one an inflection renames - still ends the walk. `docs/COMPATIBILITY.md` says both halves.
|
|
155
|
-
|
|
156
|
-
## Strong params
|
|
157
|
-
|
|
158
|
-
A controller method that returns permitted params: it calls `permit`, `permit!` or `expect` on params, or returns what `super`, another strong params method, or a call handed the whole params gives back. What it returns makes one, never its name: a `*_params` method that builds associations is not one. A controller has its own and each superclass's that no nearer class redefines.
|
|
159
|
-
|
|
160
|
-
**A call reaches the method Ruby would run.** That is the nearest class in the controller's chain that defines the name, so a parent's helper counts whatever it is called (Mastodon's `params_slice`), a child that redefines it decides for every class above it, and a class method or a nested class's method of that name redefines nothing. A name none of the controller's classes defines, a concern's, a gem's or Pundit's, is taken at its `*_params` or `permitted_*` name. ADR-0006 records why.
|
|
161
|
-
|
|
162
|
-
**An action and a strong params method can be one method.** An action that permits inline is an action, not strong params. A public `*_params` method that permits is strong params, and since Rails can dispatch to it, the **Action** reading lists it too.
|
|
163
|
-
|
|
164
|
-
## Notable gem
|
|
165
|
-
|
|
166
|
-
A gem the context calls out because it shapes how the app is built, counted only when the app itself runs it: the Gemfile names it (or a gemspec or path gem it pulls in does), it hooks into Rails on its own whichever gem brings it (a railtie or an engine, and OmniAuth), or it is a Rails framework the app loads. A gem that sits in the lockfile only because another gem needs it is not one.
|
|
167
|
-
|
|
168
|
-
## Payload
|
|
169
|
-
|
|
170
|
-
The introspection context hash, in the sense consumers read it. `Payload` is the reading side: a section guard and pinned list readers whose key pairs a spec checks against the producing introspector's own output, so a renamed key fails a test instead of silently emptying a section everywhere. Facts rendered from it live once (`SectionFacts`); membership judgments are applied at the introspector seam, and readers trust the payload rather than re-filtering it. One reader fills a section a preset leaves out: `Payload.multi_database` reads the app databases list when the `multi_database` introspector did not run, so a database's mark and the first database's own name do not depend on the preset.
|
|
171
|
-
|
|
172
|
-
## Standalone
|
|
173
|
-
|
|
174
|
-
Two senses on different axes. Qualify it; the bare word does not say which.
|
|
175
|
-
|
|
176
|
-
**Standalone install** - how the gem got installed: `gem install rails-ai-context`, driven through its own binary, never in the host app's Gemfile, so the `rails ai:*` rake tasks do not exist. `InstallMode.standalone?` is the decider, and the `standalone:` flag on `McpConfigGenerator` means this - it picks whether the written MCP command is the bare binary or a bundled invocation. `docs/STANDALONE.md` documents this sense only.
|
|
177
|
-
|
|
178
|
-
**Standalone server** - how MCP is served: the entry point that is its own process, `rails ai:serve_http` starting `Server` with `transport: :http`, serving MCP and nothing else. The middleware and the engine controller answer the same requests from inside the app's web server; the standalone server does not need one running. It says nothing about the install - an in-Gemfile app starts it from a rake task. ADR-0003 and `McpEdge` use this sense.
|
|
179
|
-
|
|
180
|
-
## App root
|
|
181
|
-
|
|
182
|
-
The directory a command reads as the app, decided once by `CLI::AppRoot` before anything reads the working directory, and then made the process's working directory. `--app-path` names it outright and is never walked. Otherwise it is the nearest directory at or above the working directory that passes the strict test, as `bin/rails` and Bundler find theirs: `config/application.rb`, `config/environment.rb`, or a `bin/rails` that boots an app or an engine. A working directory that passes only the loose test (source alone, any `app/**/*.rb`: a packwerk pack, an engine kept as code) is the root itself when no app holds it. The walk up never stops at a pack, `$HOME`, `node_modules`, `vendor/bundle` or a gem install.
|
|
183
|
-
|
|
184
|
-
Not the same as `Rails.root`, which only a booted app has, and not the **workspace** below, which is no app at all.
|
|
185
|
-
|
|
186
|
-
## Workspace
|
|
187
|
-
|
|
188
|
-
A folder an editor or agent is opened at that is no app and has no app above it, but holds apps one or two levels down (`config/application.rb` only, so an engine or a pack is not one). `init` sets one up as a whole: the AI tools are chosen once, each app gets its own `.rails-ai-context.yml` and context files, since its server reads them from its own root, and the workspace's MCP configs get one server per app, since a client reads them from the folder it opened and from nowhere below. Every other command needs a single app, so it uses the only one or refuses with one `--app-path` command per app.
|
|
189
|
-
|
|
190
|
-
**Server name** - the key an entry has in a config. An app's own config calls its server `rails-ai-context`; a workspace's calls each one `rails-ai-context-<app folder>` (`Install::Workspace.server_names`), with the whole path where two apps share a folder name, and a hash where the name would be too long for the tools. `McpConfigGenerator::OWN_SERVER_NAME` matches every form, and `own_entry?` claims the ones that run the gem's server, so removal never leaves one behind and never takes an entry somebody else named that way. Not the **announced name**, which the running server gives the tool (`config.server_name`, or, in place of its default, `RAILS_AI_CONTEXT_SERVER_NAME` for one process): a workspace server announces its app first, because VS Code names tools after that and keeps 13 characters of it.
|
|
191
|
-
|
|
192
|
-
**Workspace entry** - the server entry itself: `--app-path` relative to the workspace, because the file may be committed; spelled from the tool's own name for its config's folder (`${workspaceFolder}`) where it has one, bare where the tool starts servers in the folder it was launched in. For an in-Gemfile app it adds `BUNDLE_GEMFILE` naming the Gemfile the app's bundle reads (its own, else the shared one its `config/boot.rb` names, else the one `bundle exec` run inside the app would find), because `bundle exec` started in the workspace would never find it. ADR-0005 records the per-tool facts this rests on.
|
|
193
|
-
|
|
194
|
-
## Setup
|
|
195
|
-
|
|
196
|
-
What an install writes, one of three. Qualify it; "mode" alone names only one of its two parts.
|
|
197
|
-
|
|
198
|
-
**MCP setup** - the MCP config for each AI tool plus its context files. The default.
|
|
199
|
-
|
|
200
|
-
**CLI setup** - the context files and no MCP config (`tool_mode: cli`). The AI tool runs the tools as shell commands, and the context files tell it how.
|
|
201
|
-
|
|
202
|
-
**MCP-only setup** - the MCP config and no context files (`context_files: false`). For a user who keeps their own `CLAUDE.md` and `AGENTS.md`.
|
|
203
|
-
|
|
204
|
-
`tool_mode` is one axis (mcp or cli) and `context_files` the other, so the MCP-only setup is the mcp tool mode without context files, not a third tool mode. `Install::Program::Setup` holds the pair.
|
|
205
|
-
|
|
206
|
-
## Unattended install
|
|
207
|
-
|
|
208
|
-
An install with every question answered on the command line, the way an AI tool runs it from an **install prompt** or a script runs it in CI. A flag answers its question (`--tools`, `--mcp`, `--cli` or `--mcp-only`, `--prune`, `--hook`), `--defaults` answers the rest with their defaults, and a flag the install cannot read stops it before anything is asked or written. A default is what the app has: the recorded tools and **setup** on a re-run, all five tools and the MCP setup on a first install. A default never removes a file; only `--prune` does. ADR-0007 records why.
|
|
209
|
-
|
|
210
|
-
## Install prompt
|
|
211
|
-
|
|
212
|
-
A message a user pastes into their AI tool so the AI tool installs, changes, removes or starts using the gem in their app. `docs/PROMPTS.md` holds them. Each names its commands outright and runs an **unattended install**, since an AI tool's shell has no one at the keyboard to answer a question.
|
|
213
|
-
|
|
214
|
-
## Database family
|
|
215
|
-
|
|
216
|
-
What a database is by its adapter's name, as far as running a statement on it goes: PostgreSQL (PostGIS too), MySQL (Trilogy and MariaDB too) or SQLite. Each family knows how its database opens a read-only transaction, bounds a statement in time, explains a plan and reports table sizes; anything else is no family, and a tool that needs one refuses or says the answer is not available, naming the adapter. `DatabaseFamily` is the one answer, from a connection, a database.yml adapter or a dump's dialect. ADR-0008 records why a family is claimed by name.
|
|
217
|
-
|
|
218
|
-
Not "engine", which this glossary keeps for Rails engines and **mounted apps**, and not "adapter", which is ActiveRecord's class for one driver.
|
|
219
|
-
|
|
220
|
-
## App database
|
|
221
|
-
|
|
222
|
-
A database the app has in any environment: one database.yml configures for the running environment, one only another environment configures (Rails 8's queue, cache and cable outside production, and a replica or a database with `database_tasks` off too), or one only a committed dump names. `AppDatabases` lists them once per run, the primary first, and each knows the environments that configure it, its **database family**, its dump and its migrations. The primary is the database `ActiveRecord::Base` is connected to on the booted tier, and the running environment's first entry on the static tier. Every surface that asks "which databases" reads this list, so the schema section, the models and the migrations cannot disagree about one app.
|
|
223
|
-
|
|
224
|
-
A tool that needs a connection reaches an app database configured here and nothing else: asked for one that only production configures, it says so. A SQLite database whose file is not there is never connected to, since connecting would create the file: the answer names it and says it does not exist yet. A SQLite database other than the primary that lives in memory is refused by name too: it exists only inside the process that created it, and a new connection would open an empty one. Not the **environment** (one named Rails environment), and not a shard, which is one database's copy under another name.
|
|
225
|
-
|
|
226
|
-
## Secret column
|
|
227
|
-
|
|
228
|
-
A column whose values `rails_query` never returns, and whose name a statement may not mention: one the app lists in `query_redacted_columns`, an `encrypts` column, a built-in secret name, a name the result heuristic flags, or a name `Redaction`'s secret words cover, unless the app allows it by name in `query_allowed_columns`. An `encrypts` column is secret whatever that list holds: a statement that names it is refused, and its values come back filtered. A generated column whose expression reads a secret column is a secret column. On MySQL, MariaDB and SQLite, where no result column is traced to its table column, the rule also refuses a view whose definition names a secret column, and a `*` inside a subquery of a statement that names a table holding one. On PostgreSQL a materialized view is held to that view rule, and on SQLite a virtual table that holds a secret column is refused whole. `SecretColumns` decides for every check in the tool, from a read of the schema of the database the statement runs on and of every other schema or database the statement reaches. Only the primary falls back to the cached context's schema when its tables cannot be read. A database whose columns cannot be read at all is not queried. `docs/SECURITY.md` is the full statement of the rule.
|
|
229
|
-
|
|
230
|
-
Not redaction itself: `Redaction` scrubs secret values out of text (logs, config, source). The two share the vocabulary, so a word added to one is secret in both.
|
|
231
|
-
|
|
232
|
-
## SQL gate
|
|
233
|
-
|
|
234
|
-
What decides whether `rails_query` runs a statement: the checks on its text, and on PostgreSQL the checks on its plan. It reads the statement once, with comments taken out, and answers either the text to run or a refusal with the reason, so nothing reaches the database that the gate did not read. On PostgreSQL a statement whose plan cannot be read does not run. `SqlGate`. The read-only transaction behind it is a second guard, and the gate's checks run before it on every database family (ADR-0008). A name the gate refuses belongs to one family and is refused there alone.
|
|
235
|
-
|
|
236
|
-
## Served tool
|
|
237
|
-
|
|
238
|
-
A tool the server answers for under the app's configuration: the built-ins `skip_tools` leaves, then each custom tool whose name no tool before it claims. An app's own class that inherits the gem's base tool counts with the built-ins once it is loaded, whether or not `custom_tools` lists it. Every surface reads the one set: `tools/list`, the CLI's list and lookup, the tool guide, the context files, `rails_get_context`'s includes and every tool name an answer prints. A tool outside it is never offered, run or pointed at. An answer or a guide step names or runs a tool only when the app serves the gem's own tool under that name: a custom tool in a built-in's place is listed, and nothing gives it the built-in's arguments. A custom tool named `rails_get_config` takes the built-in's place when `skip_tools` lists that name, and the **config sections** go with the built-in.
|
|
239
|
-
|
|
240
|
-
## Config section
|
|
241
|
-
|
|
242
|
-
One part of the app's configuration that `rails_get_config` answers by `section`: `app` (the default: database, auth, assets, cache, queue, Action Cable, initializers, middleware), `gems`, `frontend`, `api`, `engines`, `conventions`, `autoload`, `active_support`, `env` (the **process environment**), `env_config` (the **environment config**) and `i18n`. Ten of them were tools of their own; their names still reach their section from the CLI and the MCP server, and `skip_tools` lists them to turn a section off. With `app` off, a call has to name its `section`. ADR-0010.
|
data/Rakefile
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/gem_tasks"
|
|
4
|
-
require "rspec/core/rake_task"
|
|
5
|
-
|
|
6
|
-
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
-
|
|
8
|
-
# E2E harness - runs specs under spec/e2e/ with E2E=1 so they're not
|
|
9
|
-
# excluded by spec/spec_helper.rb. Each install-path spec spawns a
|
|
10
|
-
# fresh Rails app in a tmpdir and can take minutes.
|
|
11
|
-
#
|
|
12
|
-
# Usage:
|
|
13
|
-
# bundle exec rake e2e # full suite
|
|
14
|
-
# bundle exec rake e2e:in_gemfile # just Path A
|
|
15
|
-
# bundle exec rake e2e:standalone # just Path B
|
|
16
|
-
# bundle exec rake e2e:zero_config # just Path C
|
|
17
|
-
# bundle exec rake e2e:workspace # just Path D: init in a folder of apps
|
|
18
|
-
# bundle exec rake e2e:mcp # stdio + HTTP protocol specs
|
|
19
|
-
namespace :e2e do
|
|
20
|
-
RSpec::Core::RakeTask.new(:in_gemfile) do |t|
|
|
21
|
-
t.pattern = "spec/e2e/in_gemfile_install_spec.rb"
|
|
22
|
-
ENV["E2E"] = "1"
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
RSpec::Core::RakeTask.new(:standalone) do |t|
|
|
26
|
-
t.pattern = "spec/e2e/standalone_install_spec.rb"
|
|
27
|
-
ENV["E2E"] = "1"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
RSpec::Core::RakeTask.new(:zero_config) do |t|
|
|
31
|
-
t.pattern = "spec/e2e/zero_config_install_spec.rb"
|
|
32
|
-
ENV["E2E"] = "1"
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
RSpec::Core::RakeTask.new(:workspace) do |t|
|
|
36
|
-
t.pattern = "spec/e2e/workspace_install_spec.rb"
|
|
37
|
-
ENV["E2E"] = "1"
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
RSpec::Core::RakeTask.new(:mcp) do |t|
|
|
41
|
-
t.pattern = "spec/e2e/mcp_{stdio,http}_protocol_spec.rb"
|
|
42
|
-
ENV["E2E"] = "1"
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
desc "Run the full E2E harness (fresh Rails app per install path)"
|
|
47
|
-
RSpec::Core::RakeTask.new(:e2e) do |t|
|
|
48
|
-
t.pattern = "spec/e2e/**/*_spec.rb"
|
|
49
|
-
ENV["E2E"] = "1"
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
namespace :e2e do
|
|
53
|
-
desc "Run e2e specs in parallel via parallel_tests (opt-in; uses all CPUs)"
|
|
54
|
-
task :parallel do
|
|
55
|
-
# Opt-in parallelization. Each parallel_tests worker is a separate
|
|
56
|
-
# process - it will rebuild its own shared-fixture memoization and
|
|
57
|
-
# its own built .gem artefact. Still a net win because rspec-level
|
|
58
|
-
# wall-clock scales with the slowest worker, not the sum.
|
|
59
|
-
#
|
|
60
|
-
# Caveats:
|
|
61
|
-
# - postgres_install_spec is excluded here because its DB name is
|
|
62
|
-
# derived from E2E_DB_SUFFIX and workers would collide unless you
|
|
63
|
-
# pre-create per-worker databases. Run `rake e2e` separately if
|
|
64
|
-
# you need postgres coverage.
|
|
65
|
-
# - Shared BUNDLE_PATH is NOT used in parallel mode - two workers
|
|
66
|
-
# writing to the same bundle path can corrupt native extension
|
|
67
|
-
# builds. Each worker gets its own implicit bundle directory.
|
|
68
|
-
require "shellwords"
|
|
69
|
-
spec_files = Dir["spec/e2e/**/*_spec.rb"].reject { |p| p.include?("postgres_install_spec") }
|
|
70
|
-
cmd = [
|
|
71
|
-
"bundle", "exec", "parallel_rspec",
|
|
72
|
-
"--serialize-stdout",
|
|
73
|
-
"--"
|
|
74
|
-
] + spec_files
|
|
75
|
-
env = { "E2E" => "1", "BUNDLE_PATH" => nil }
|
|
76
|
-
sh(env, *cmd) { |ok, _res| abort("parallel_rspec failed") unless ok }
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
task default: :spec
|
data/docs/RELEASING.md
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# Releasing
|
|
2
|
-
|
|
3
|
-
The tag publishes, not the merge. `.github/workflows/release.yml` runs on a
|
|
4
|
-
pushed `v*` tag and does three things in order: the unit matrix, a gate that
|
|
5
|
-
demands an already-green E2E run for that exact commit, then the publish to
|
|
6
|
-
RubyGems, the GitHub release and the MCP registry.
|
|
7
|
-
|
|
8
|
-
Work this list top to bottom. Every item is checkable before the tag exists,
|
|
9
|
-
which matters because a tag that fails the version check has already been
|
|
10
|
-
pushed.
|
|
11
|
-
|
|
12
|
-
## Before the release commit
|
|
13
|
-
|
|
14
|
-
1. **The Changed section picks the number.** Read `## [Unreleased]` before
|
|
15
|
-
renaming it. Anything under `Changed` that a consumer can observe - a
|
|
16
|
-
renamed key in an MCP resource or a generated file, a flag that now refuses,
|
|
17
|
-
an exit code that moved, a default that flipped - rules out a patch bump.
|
|
18
|
-
Added entries rule one out too. A release whose whole section is `Fixed` is
|
|
19
|
-
the patch case. Nothing downstream checks this, so it is decided here or not
|
|
20
|
-
at all.
|
|
21
|
-
2. **The changelog section is the release notes.** Rename `## [Unreleased]` to
|
|
22
|
-
`## [X.Y.Z] - YYYY-MM-DD`. The publish job extracts the notes with an awk
|
|
23
|
-
range anchored on `^## \[X.Y.Z\]`, so a heading that does not match that
|
|
24
|
-
shape exactly ships a release whose body is a link to the file.
|
|
25
|
-
3. **The version lives in two files and they must agree with the tag.**
|
|
26
|
-
`lib/rails_ai_context/version.rb` is the one the workflow checks: the
|
|
27
|
-
publish job compares the tag minus its `v` against `RailsAiContext::VERSION`
|
|
28
|
-
and fails when they differ. `server.json` carries the same number for the
|
|
29
|
-
MCP registry. The workflow rewrites `server.json` in its own checkout and
|
|
30
|
-
commits the bump back to `main` on its own, so a stale value there costs an
|
|
31
|
-
extra bot commit rather than a failed release. Bump both in the release
|
|
32
|
-
commit anyway.
|
|
33
|
-
4. **Prose that names the last version goes stale where no check can see it.**
|
|
34
|
-
`docs/COMPATIBILITY.md` names the release its proof sources come from. Grep
|
|
35
|
-
the tree for the previous version before committing. The repository's About
|
|
36
|
-
text on GitHub names the tool count, and no check reads it:
|
|
37
|
-
`gh repo edit --description` changes it.
|
|
38
|
-
5. **The copy-paste prompts name a version floor.** `docs/PROMPTS.md` (and the
|
|
39
|
-
README's copy of the recommended prompt) tells the AI tool to install
|
|
40
|
-
`>= 5.35.0`, the first release with the install flags. If the release that
|
|
41
|
-
first ships them gets another number, change every `5.35.0` there to it.
|
|
42
|
-
Tag that release in the same session as the merge: until it is on
|
|
43
|
-
RubyGems, a prompt pasted from the README on `main` cannot install it.
|
|
44
|
-
A change to the install, its flags or a prompt is proven by the prompt
|
|
45
|
-
bench in the qa-rails-ai-context skill (`prompt-bench.py`), not by
|
|
46
|
-
`docs_prompts_spec`, which only checks that each flag exists.
|
|
47
|
-
|
|
48
|
-
## Before the tag
|
|
49
|
-
|
|
50
|
-
6. **CI is green on the exact commit that will be tagged.** Check the run, do
|
|
51
|
-
not infer it from a local pass. The release matrix is stricter than a local
|
|
52
|
-
run in two ways: it runs seeds 1, 27377 and 90210 rather than a random one,
|
|
53
|
-
and it runs `--order defined` as a fourth ordering no seed reproduces.
|
|
54
|
-
7. **A successful E2E run exists for that same SHA.** The E2E workflow does not
|
|
55
|
-
run on tags, and the release's `e2e-gate` job asks the API for a completed
|
|
56
|
-
successful `e2e.yml` run whose `head_sha` is this commit. Start it by hand
|
|
57
|
-
from Actions and let it finish before tagging.
|
|
58
|
-
|
|
59
|
-
## Tagging
|
|
60
|
-
|
|
61
|
-
8. Tag the merge commit and push the tag. Nothing publishes until the tag is on
|
|
62
|
-
the remote.
|
|
63
|
-
|
|
64
|
-
## After
|
|
65
|
-
|
|
66
|
-
9. **Confirm the release object exists and is not a draft**, and that its notes
|
|
67
|
-
are this version's changelog section rather than the fallback sentence.
|
|
68
|
-
10. **Confirm the gem is on RubyGems and the version is in the MCP registry.**
|
|
69
|
-
Both jobs skip themselves when the version is already published, so a skip
|
|
70
|
-
in the log is not the same as a failure.
|
|
71
|
-
11. **Close what the release closed.** On GitHub a comma list after one `Closes`
|
|
72
|
-
keyword closes only the first issue. Write the keyword once per issue.
|
data/docs/_config.yml
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
title: rails-ai-context
|
|
2
|
-
description: 35 MCP tools that give AI agents live access to your Rails schema, models, routes & conventions.
|
|
3
|
-
theme: jekyll-theme-minimal
|
|
4
|
-
plugins:
|
|
5
|
-
- jekyll-relative-links
|
|
6
|
-
|
|
7
|
-
relative_links:
|
|
8
|
-
enabled: true
|
|
9
|
-
collections: true
|
|
10
|
-
|
|
11
|
-
defaults:
|
|
12
|
-
- scope:
|
|
13
|
-
path: ""
|
|
14
|
-
values:
|
|
15
|
-
layout: default
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
<!-- Mermaid diagram rendering -->
|
|
2
|
-
<script type="module">
|
|
3
|
-
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
4
|
-
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
|
5
|
-
|
|
6
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
7
|
-
document.querySelectorAll('pre > code.language-mermaid').forEach(el => {
|
|
8
|
-
const pre = el.parentElement;
|
|
9
|
-
const div = document.createElement('div');
|
|
10
|
-
div.className = 'mermaid';
|
|
11
|
-
div.textContent = el.textContent;
|
|
12
|
-
pre.parentElement.replaceChild(div, pre);
|
|
13
|
-
});
|
|
14
|
-
mermaid.run();
|
|
15
|
-
});
|
|
16
|
-
</script>
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# Derive Prism listener registration ourselves, not via prism's register_public_methods
|
|
2
|
-
|
|
3
|
-
Status: accepted
|
|
4
|
-
|
|
5
|
-
Prism 1.9+ ships `Dispatcher#register_public_methods`, which looks like the obvious replacement for the hand-typed event allowlist that shipped the fabricated-static-routes bug (c8caa40). We derive registration in our own module instead, for two reasons that are easy to rediscover the hard way: the gem's prism floor is below 1.9, where the helper does not exist, and the helper is built on `public_methods(false)`, which skips inherited handlers - `VariantCallListener` inherits its only handler from `ChainedCallListener` and would register zero events, silently.
|
|
6
|
-
|
|
7
|
-
(The floor was 0.28 when this was written and is 1.4 now; the first reason holds either way. See the gemspec for the current range, and the `prism floor` CI leg that tests it.)
|
|
8
|
-
|
|
9
|
-
Our module enumerates `on_*` handlers inheritance-aware and validates every name against the running prism's real event set, raising on unknowns, so an unregistered or typo'd handler fails loud instead of never firing. Do not swap the prism helper back in as a simplification; it reintroduces both silent failure modes.
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
# Static tier capability is declared per introspector, never auto-detected
|
|
2
|
-
|
|
3
|
-
Status: accepted
|
|
4
|
-
|
|
5
|
-
Every mapped introspector must declare how it answers in the static tier: its `call` already works from files alone, or it refuses honestly, or it defines `static_call` against an alternate static source. We rejected auto-detecting "file-only" introspectors because a default-open guess re-creates the failure mode the tier exists to prevent: an introspector that grows a runtime dependency while still looking file-only would silently serve wrong data (the c8caa40 class). Declaration fails closed - a mapped introspector that declares nothing is a spec failure, and a shared fixture spec proves every files-only declaration true under `StaticApp`.
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# The two shared tool caches: what is a defect and what is intended
|
|
2
|
-
|
|
3
|
-
Status: accepted
|
|
4
|
-
|
|
5
|
-
`Tools::BaseTool` keeps two process-global structures - `SHARED_CACHE` (one introspection result for every tool) and `SESSION_CONTEXT` (what has been called with what). Both are read by four entry points - stdio, the Rack middleware, the engine controller and the standalone HTTP server - three of which serve many clients from one process. This records what the audit found, so the next reader does not re-derive it from the mutex.
|
|
6
|
-
|
|
7
|
-
## Defects, fixed
|
|
8
|
-
|
|
9
|
-
**One client's session record was served to another.** `SESSION_CONTEXT[:queries]` was a single flat hash. Over stdio that is right - one process is one conversation - but the middleware, the engine controller and the standalone HTTP server each serve every client from one process, so `rails_session_context` listed calls the asking client never made. The record is now bucketed per conversation: all three HTTP entry points wrap each request in `BaseTool.with_session(<Mcp-Session-Id>)`, and stdio falls through to a single default bucket, unchanged.
|
|
10
|
-
|
|
11
|
-
**The scope ended before the tool ran.** The SDK answers a request inside a session with a streaming body, and the tool runs when the web server calls that body - after the entry point has returned, so the scope around `handle_request` was already gone and the middleware and the standalone server recorded every client in the default bucket again. `McpEdge.serve` hands the web server a body that re-enters the client's session. The engine controller calls the body itself, inside its own scope.
|
|
12
|
-
|
|
13
|
-
**One client's reset emptied every client's record.** `action: "reset"` cleared the whole hash. It clears the calling conversation's bucket now (`current_session_reset!`). `session_reset!` still clears them all, for a change that makes every earlier answer stale: live reload.
|
|
14
|
-
|
|
15
|
-
**`session_queries` handed out live entries.** It returned `values.dup`, a shallow copy, so the entry hashes stayed live inside the record and kept being mutated by later calls - a caller's snapshot changed under it. It now copies each entry.
|
|
16
|
-
|
|
17
|
-
**The record grew without bound, keyed by a client-controlled header.** Bucketing per conversation introduced a hash whose keys come from `Mcp-Session-Id`, in a process that stays up. Three things were wrong at once: the hash had a default block, so merely *reading* a session's history created a bucket; nothing capped the id's length; and nothing evicted. Reading no longer writes, ids are truncated to `MAX_SESSION_ID_LENGTH`, and the number of remembered conversations is capped at `MAX_SESSIONS`.
|
|
18
|
-
|
|
19
|
-
**A composite tool's inner calls were recorded as the client's.** `rails_get_context`, `rails_diagnose` and `rails_review_changes` answer by calling other tools, and every call recorded itself, so one `rails_get_context` read as four calls the client never made, and the first inner call took the outer call's params with it. SafeCall now marks the outermost call on the thread, and only that call is recorded, once, with its own params.
|
|
20
|
-
|
|
21
|
-
Eviction is least-recently-used, not oldest-created. Recording re-inserts the session so hash order tracks use. Ordering by creation instead would evict the conversation that has run for hours ahead of a hundred idle newcomers - backwards, and worst on exactly the long-lived transports that made bucketing necessary.
|
|
22
|
-
|
|
23
|
-
## Intended semantics, not defects
|
|
24
|
-
|
|
25
|
-
**The fingerprint walk happens under the mutex, not outside it.** The whole read path in `cached_context` - TTL check, fingerprint comparison, re-introspection - is inside one `synchronize`, so concurrent callers serialize instead of racing to introspect. Twenty threads arriving together produce exactly one introspection. The cost is that a fingerprint walk (~12ms in dev-mode installs, ~0.5ms in production) blocks other tool calls for its duration. That is the trade we want: a lock-free fast path would let several threads run the full 40-introspector walk at once, which is far more expensive than the wait it avoids.
|
|
26
|
-
|
|
27
|
-
**`deep_dup` also happens under the mutex.** Every caller gets an independent copy, so a tool that mutates its context cannot corrupt the next tool's view. Copying inside the lock adds to hold time, and moving it outside would hand out the cached object itself between the read and the copy. Isolation is worth more than the contention; the copy is cheap next to the introspection it protects.
|
|
28
|
-
|
|
29
|
-
Both are pinned by `spec/lib/rails_ai_context/tools/shared_cache_concurrency_spec.rb`, which also drives the middleware and the engine controller concurrently against both caches.
|
|
30
|
-
|
|
31
|
-
**Every server checks the files outside the mutex, at the start of each call.** Within the TTL the fast path trusts the cache, so something has to drop it on an edit before a call reads it. Live reload cannot: `listen` delivers a change after its debounce, and with no `listen` in the bundle nothing delivers it at all. So `refresh_if_files_changed!` checks the files itself (`Fingerprinter::Snapshot`, a stat of each watched directory and file), and on a change reloads the app's code and resets the caches. That check cannot share the mutex: the code reload waits for running calls to finish, and those may be waiting on the mutex. Only a check that began after a call arrived can stand in for the call's own - an earlier one may have passed a file before it was edited - so calls made at once wait for one check together, and calls one after another each check. Sharing any check made within ten walks' time, as the first version did, answered a call made right after an edit from before it.
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# The install e2e specs keep their examples; the fast specs took the load instead
|
|
2
|
-
|
|
3
|
-
Status: accepted
|
|
4
|
-
|
|
5
|
-
The install program (#116, #117) planned to shrink e2e install coverage to "one smoke per entry", on the stated grounds that install changes were paying a 30-minute loop. Measured before acting, that premise does not hold, so the trim was not made.
|
|
6
|
-
|
|
7
|
-
## What it costs now
|
|
8
|
-
|
|
9
|
-
The whole e2e suite is about 4 minutes for 159 examples, and builds 11 throwaway Rails apps. The three install specs account for 3 of those apps and 31 examples in roughly 73 seconds.
|
|
10
|
-
|
|
11
|
-
Within one install spec, the app is the cost and the examples are nearly free:
|
|
12
|
-
|
|
13
|
-
| Run | Time |
|
|
14
|
-
|-----|------|
|
|
15
|
-
| `standalone_install_spec.rb`, all 7 examples | 9.6s |
|
|
16
|
-
| the same file, 1 example | 6.5s |
|
|
17
|
-
|
|
18
|
-
So `rails new` plus `bundle install` is ~6.2s and each further example is ~0.5s. Cutting all three files to one example each would drop ~28 examples and save ~14 seconds of a 4-minute suite. The apps - the actual cost - would all still be built, because the three install paths (in-Gemfile, standalone, zero-config) are what the separate apps exist to distinguish.
|
|
19
|
-
|
|
20
|
-
## What it would cost to trim
|
|
21
|
-
|
|
22
|
-
Three of the surviving install examples have no unit-level equivalent, because they exercise the generator's prompting rather than its output: stdin hitting EOF mid-prompt, `--defaults` skipping every prompt, and a re-run being idempotent. Those are the paths that broke before, and they cannot be reproduced without running the real generator against a real app.
|
|
23
|
-
|
|
24
|
-
## The part of the goal that was real
|
|
25
|
-
|
|
26
|
-
The intent behind the bullet - that install changes stop paying a slow loop - is served by moving the questions that used to need an app into fast specs. `Install::SelectionRecord` and `Install::AiTool` are unit-tested with no Rails boot, and the entry round-trip that used to justify three e2e runs is now `spec/lib/rails_ai_context/install/entry_parity_spec.rb`, which runs in milliseconds.
|
|
27
|
-
|
|
28
|
-
Revisit this if the app-build count grows or if a fixture app can be built once and shared across install paths. Deleting examples is not the lever.
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# rails_query runs only on a database family it can guard
|
|
2
|
-
|
|
3
|
-
Status: accepted
|
|
4
|
-
|
|
5
|
-
`rails_query` promises two guards the database itself enforces: a read-only transaction and a statement timeout. Each database server spells both differently, and some cannot give one at all, so the tool runs a statement only where it knows the spelling. A **database family** is that knowledge: one per kind of database, told by the adapter's name, and the only place a database's read-only and timeout rules live. `rails_query` reads the name from the database's configuration, before any connection opens.
|
|
6
|
-
|
|
7
|
-
## Decision
|
|
8
|
-
|
|
9
|
-
- Three families: PostgreSQL (PostGIS too), MySQL (Trilogy and MariaDB too) and SQLite. Any other adapter gets a refusal that names it before anything reaches the database. Statistics answers (table sizes, row estimates, index usage) say "not available" for it instead.
|
|
10
|
-
- A family is claimed by the adapter's name alone, as `adapter_name` and database.yml spell it. CockroachDB's adapter subclasses PostgreSQL's, but it has no `EXPLAIN (FORMAT JSON)` and its own read-only rules, so it is refused until someone verifies them.
|
|
11
|
-
- The timeout per family:
|
|
12
|
-
- PostgreSQL: `SET LOCAL statement_timeout` inside the read-only transaction, at one millisecond or more, since zero turns the timeout off.
|
|
13
|
-
- MySQL: the session's `max_execution_time`, read first, set for the statement and restored after. The `MAX_EXECUTION_TIME` hint binds only the outermost SELECT and leaves a statement that starts with WITH or EXPLAIN with no bound, so it is not used.
|
|
14
|
-
- MariaDB: the session's `max_statement_time`, read first, set for the statement and restored after. MariaDB ignores the MySQL hint and has no `max_execution_time`.
|
|
15
|
-
- SQLite: a forked child with a deadline, since sqlite3-ruby holds the interpreter lock while a statement steps and nothing in the process can end it. A statement runs on the app's own connection in five cases: an in-memory database, a platform without `fork`, a statement the child cannot prepare, a transaction open on that connection, and, while that connection holds a temp object or an attachment, a statement that names one of them, the temp catalog or schema, or calls a pragma function or `dbstat`. There a watchdog's interrupt ends a statement that returns rows slowly.
|
|
16
|
-
- What bounds each kind of statement a call sends:
|
|
17
|
-
- The query and `explain: true` have `query_timeout` to themselves: `statement_timeout` on PostgreSQL, the session's statement limit with `lock_wait_timeout` on MySQL and MariaDB, the child's deadline on SQLite.
|
|
18
|
-
- The reads beside the query (the schema, the PostgreSQL plan with its `pg_proc` lookup, the column trace) run in a block the family bounds the same way. They share a second `query_timeout`, and each is charged the time it ran. On SQLite they run on the app's own connection with no statement limit, and a wait on another writer ends at its busy timeout.
|
|
19
|
-
- `rails_runtime_info`'s table sizes, index usage and pending migrations each run in such a block with `query_timeout` of their own.
|
|
20
|
-
- Stated limits of the bound:
|
|
21
|
-
- SQLite cannot end one long step on the app's own connection: an interrupt lands only between two rows. That run is reported as one without a time limit.
|
|
22
|
-
- PostgreSQL has no client read timeout. `statement_timeout` is the server's to keep, so a server that goes silent after it connects is not ended by the client. A leased MySQL or MariaDB connection gets a `read_timeout` of twice `query_timeout` plus 5 seconds unless its entry sets one.
|
|
23
|
-
- MySQL and MariaDB take `lock_wait_timeout` in whole seconds. It is set because their statement limit does not cover `EXPLAIN` or `SHOW`, and a wait on a lock ends at `query_timeout` rounded up to the next second.
|
|
24
|
-
- MySQL's statement limit holds `SELECT` only, so the gate refuses `SHOW` with a `WHERE` clause on every family.
|
|
25
|
-
- A timeout is recognised by `ActiveRecord::QueryAborted` (QueryCanceled, StatementTimeout) or by the database's native code. MariaDB's 1969 has no Rails class in any version. Message text is never read, so a missing column named "timeout" is reported as an SQL error.
|
|
26
|
-
- The statement checks before execution stay on every family. MySQL's read-only transaction lets `FOR SHARE`, `LOCK IN SHARE MODE`, `INTO @var` and `INTO OUTFILE` through, so it cannot be the only guard.
|
|
27
|
-
- Only PostgreSQL traces a result column to its table column, through the statement's plan, so there a statement runs only with a plan. MySQL, MariaDB and SQLite have no such trace. There a view is checked by the definition the database stores and refused when it names a secret column, and a `*` inside a subquery is refused when the statement names a table that holds one. A PostgreSQL materialized view stores its rows, so the plan traces none of its columns either, and it is held to the same view rule.
|
|
28
|
-
- A guarded read leaves an open transaction as it found it: a savepoint on PostgreSQL, and a refusal on MySQL and MariaDB, which cannot make an open transaction read-only.
|
|
29
|
-
|
|
30
|
-
## Consequences
|
|
31
|
-
|
|
32
|
-
Apps on SQL Server, Oracle or CockroachDB have no `rails_query`. On SQL Server and Oracle the `LIMIT` its row cap appends is a syntax error, so a query without its own row limit fails there. Supporting another database means adding a family with its own read-only and timeout spelling, verified against a real server.
|
|
33
|
-
|
|
34
|
-
## Alternatives not taken
|
|
35
|
-
|
|
36
|
-
- **Running other adapters on the statement checks alone**: the tool's description promises database-level guards, and a check on text is not one.
|
|
37
|
-
- **Classifying by class ancestry**: it would admit a database whose guards nobody has run.
|
|
38
|
-
- **Injecting the MySQL hint after a WITH list**: it needs a parse of the statement to find the outermost SELECT. The session variable needs none, and it bounds EXPLAIN ANALYZE too.
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# rails_query stays in Ruby and takes dbhub's shapes
|
|
2
|
-
|
|
3
|
-
Status: accepted
|
|
4
|
-
|
|
5
|
-
bytebase/dbhub is a database MCP server: one connector per kind of database, named sources, a single read-only policy, and two tools by default. The 2026-10 architecture review read this gem through it and asked whether `rails_query` should hand its work to dbhub outright.
|
|
6
|
-
|
|
7
|
-
## Decision
|
|
8
|
-
|
|
9
|
-
- `rails_query` stays the gem's own. dbhub needs Node 22.5 or later, while this gem is pure Ruby. dbhub connects by DSN; `rails_query` runs on the app's own connection, so database.yml, SSL settings, SQLite extensions and app-registered functions all apply. dbhub returns column values unredacted, and the **secret column** rule is this tool's main safety property. The production guard and the static-tier refusal would also go.
|
|
10
|
-
- The shapes come over:
|
|
11
|
-
- one adapter per **database family** (dbhub's connector), ADR-0008;
|
|
12
|
-
- one list of **app databases** with the primary first (dbhub's named sources);
|
|
13
|
-
- one module deciding what a statement may do (dbhub's access policy): the **SQL gate**, with the **secret column** rule beside it;
|
|
14
|
-
- one answer to which tools are served;
|
|
15
|
-
- a short default tool list, through **config sections** (ADR-0010).
|
|
16
|
-
- A non-primary database is reached through a `database` argument on the tools that run SQL. dbhub registers `execute_sql_<source>` per source. Here a tool per database would add tool definitions to every turn and go stale when database.yml changes under a running server.
|
|
17
|
-
|
|
18
|
-
## Consequences
|
|
19
|
-
|
|
20
|
-
A user who needs write access, or a database the Rails app does not own, can run dbhub beside this gem. `rails_query` keeps its read-only guards and its secret column rule for the app's own databases either way.
|
|
21
|
-
|
|
22
|
-
## Alternatives not taken
|
|
23
|
-
|
|
24
|
-
- **dbhub as rails_query's backend**: it would add Node to a pure Ruby gem, connect by DSN where `rails_query` uses the app's own connection, and return secret columns unredacted. Each of those is a property `rails_query` documents.
|
|
25
|
-
- **dbhub's two-tool default**: the answers here are computed (callback order, strong params lineage, filter chains). A bare search tool would hand that work back to the model.
|
|
26
|
-
- **Tool annotations derived from an access policy**: there is no write mode here, so a constant read-only hint is correct.
|