rails-ai-context 5.19.1 → 5.20.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 (139) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/CONTEXT.md +17 -1
  4. data/CONTRIBUTING.md +7 -5
  5. data/app/controllers/rails_ai_context/mcp_controller.rb +27 -30
  6. data/docs/COMPATIBILITY.md +1 -1
  7. data/docs/INTROSPECTORS.md +1 -1
  8. data/docs/SECURITY.md +37 -6
  9. data/docs/SETUP.md +1 -1
  10. data/docs/TROUBLESHOOTING.md +1 -1
  11. data/docs/adr/0001-own-listener-registration.md +9 -0
  12. data/docs/adr/0002-static-tier-declared-not-detected.md +5 -0
  13. data/docs/adr/0003-shared-tool-cache-semantics.md +23 -0
  14. data/docs/adr/0004-install-e2e-stays-example-rich.md +28 -0
  15. data/exe/rails-ai-context +55 -79
  16. data/gemfiles/prism_floor.gemfile +20 -0
  17. data/gemfiles/prism_head.gemfile +19 -0
  18. data/lib/generators/rails_ai_context/install/install_generator.rb +23 -63
  19. data/lib/rails_ai_context/cli.rb +12 -0
  20. data/lib/rails_ai_context/detail_level.rb +5 -0
  21. data/lib/rails_ai_context/doctor.rb +12 -14
  22. data/lib/rails_ai_context/hydrators/controller_hydrator.rb +1 -7
  23. data/lib/rails_ai_context/hydrators.rb +8 -0
  24. data/lib/rails_ai_context/install/ai_tool.rb +75 -0
  25. data/lib/rails_ai_context/install/cleanup.rb +42 -0
  26. data/lib/rails_ai_context/install/selection_record.rb +220 -0
  27. data/lib/rails_ai_context/install.rb +8 -0
  28. data/lib/rails_ai_context/introspector.rb +7 -5
  29. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +3 -0
  30. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +3 -0
  31. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +3 -0
  32. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -0
  33. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -0
  34. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +3 -0
  35. data/lib/rails_ai_context/introspectors/auth_introspector.rb +3 -0
  36. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +3 -0
  37. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -0
  38. data/lib/rails_ai_context/introspectors/config_introspector.rb +3 -0
  39. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +3 -0
  40. data/lib/rails_ai_context/introspectors/controller_introspector.rb +3 -0
  41. data/lib/rails_ai_context/introspectors/convention_introspector.rb +3 -0
  42. data/lib/rails_ai_context/introspectors/credentials_introspector.rb +3 -0
  43. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +3 -0
  44. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -0
  45. data/lib/rails_ai_context/introspectors/engine_introspector.rb +3 -0
  46. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +4 -25
  47. data/lib/rails_ai_context/introspectors/env_introspector.rb +3 -0
  48. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +3 -0
  49. data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -0
  50. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -0
  51. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +3 -0
  52. data/lib/rails_ai_context/introspectors/job_introspector.rb +3 -0
  53. data/lib/rails_ai_context/introspectors/listener_registration.rb +71 -0
  54. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +20 -3
  55. data/lib/rails_ai_context/introspectors/listeners.rb +10 -0
  56. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +3 -0
  57. data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -4
  58. data/lib/rails_ai_context/introspectors/model_introspector.rb +3 -0
  59. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +3 -0
  60. data/lib/rails_ai_context/introspectors/observability_introspector.rb +3 -0
  61. data/lib/rails_ai_context/introspectors/performance_introspector.rb +3 -0
  62. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +3 -0
  63. data/lib/rails_ai_context/introspectors/route_introspector.rb +3 -0
  64. data/lib/rails_ai_context/introspectors/schema_introspector.rb +3 -0
  65. data/lib/rails_ai_context/introspectors/security_introspector.rb +3 -0
  66. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +3 -0
  67. data/lib/rails_ai_context/introspectors/source_introspector.rb +7 -27
  68. data/lib/rails_ai_context/introspectors/static_tier.rb +67 -0
  69. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +3 -0
  70. data/lib/rails_ai_context/introspectors/test_introspector.rb +3 -0
  71. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +3 -0
  72. data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -0
  73. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +3 -0
  74. data/lib/rails_ai_context/introspectors.rb +8 -0
  75. data/lib/rails_ai_context/legacy_cleanup.rb +1 -6
  76. data/lib/rails_ai_context/mcp_config_generator.rb +1 -7
  77. data/lib/rails_ai_context/mcp_edge.rb +43 -0
  78. data/lib/rails_ai_context/middleware.rb +5 -22
  79. data/lib/rails_ai_context/redaction.rb +246 -0
  80. data/lib/rails_ai_context/serializers/context_file_serializer.rb +9 -8
  81. data/lib/rails_ai_context/serializers/section_guard.rb +3 -4
  82. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +13 -56
  83. data/lib/rails_ai_context/serializers.rb +8 -0
  84. data/lib/rails_ai_context/server.rb +4 -12
  85. data/lib/rails_ai_context/tasks/rails_ai_context.rake +58 -142
  86. data/lib/rails_ai_context/tools/analyze_feature.rb +7 -2
  87. data/lib/rails_ai_context/tools/base_tool.rb +132 -7
  88. data/lib/rails_ai_context/tools/dependency_graph.rb +7 -2
  89. data/lib/rails_ai_context/tools/diagnose.rb +7 -0
  90. data/lib/rails_ai_context/tools/generate_test.rb +7 -0
  91. data/lib/rails_ai_context/tools/get_active_support.rb +18 -16
  92. data/lib/rails_ai_context/tools/get_api.rb +19 -23
  93. data/lib/rails_ai_context/tools/get_autoload.rb +30 -28
  94. data/lib/rails_ai_context/tools/get_callbacks.rb +22 -21
  95. data/lib/rails_ai_context/tools/get_component_catalog.rb +39 -39
  96. data/lib/rails_ai_context/tools/get_concern.rb +8 -1
  97. data/lib/rails_ai_context/tools/get_config.rb +65 -63
  98. data/lib/rails_ai_context/tools/get_context.rb +7 -1
  99. data/lib/rails_ai_context/tools/get_controllers.rb +116 -113
  100. data/lib/rails_ai_context/tools/get_conventions.rb +72 -70
  101. data/lib/rails_ai_context/tools/get_edit_context.rb +7 -0
  102. data/lib/rails_ai_context/tools/get_engines.rb +36 -34
  103. data/lib/rails_ai_context/tools/get_env.rb +17 -8
  104. data/lib/rails_ai_context/tools/get_env_config.rb +27 -24
  105. data/lib/rails_ai_context/tools/get_frontend_stack.rb +19 -23
  106. data/lib/rails_ai_context/tools/get_gems.rb +31 -29
  107. data/lib/rails_ai_context/tools/get_helper_methods.rb +7 -3
  108. data/lib/rails_ai_context/tools/get_i18n.rb +17 -14
  109. data/lib/rails_ai_context/tools/get_job_pattern.rb +7 -1
  110. data/lib/rails_ai_context/tools/get_mailers.rb +28 -25
  111. data/lib/rails_ai_context/tools/get_model_details.rb +66 -64
  112. data/lib/rails_ai_context/tools/get_partial_interface.rb +8 -3
  113. data/lib/rails_ai_context/tools/get_routes.rb +140 -139
  114. data/lib/rails_ai_context/tools/get_schema.rb +151 -149
  115. data/lib/rails_ai_context/tools/get_service_pattern.rb +7 -1
  116. data/lib/rails_ai_context/tools/get_stimulus.rb +110 -109
  117. data/lib/rails_ai_context/tools/get_test_info.rb +123 -122
  118. data/lib/rails_ai_context/tools/get_turbo_map.rb +7 -3
  119. data/lib/rails_ai_context/tools/get_view.rb +8 -3
  120. data/lib/rails_ai_context/tools/migration_advisor.rb +7 -0
  121. data/lib/rails_ai_context/tools/onboard.rb +7 -0
  122. data/lib/rails_ai_context/tools/performance_check.rb +75 -75
  123. data/lib/rails_ai_context/tools/query.rb +20 -4
  124. data/lib/rails_ai_context/tools/read_logs.rb +8 -66
  125. data/lib/rails_ai_context/tools/review_changes.rb +7 -0
  126. data/lib/rails_ai_context/tools/runtime_info.rb +8 -1
  127. data/lib/rails_ai_context/tools/safe_call.rb +40 -1
  128. data/lib/rails_ai_context/tools/search_code.rb +7 -0
  129. data/lib/rails_ai_context/tools/search_docs.rb +7 -0
  130. data/lib/rails_ai_context/tools/section_fetch.rb +81 -0
  131. data/lib/rails_ai_context/tools/security_scan.rb +7 -1
  132. data/lib/rails_ai_context/tools/session_context.rb +7 -0
  133. data/lib/rails_ai_context/tools/validate.rb +7 -0
  134. data/lib/rails_ai_context/tools.rb +8 -0
  135. data/lib/rails_ai_context/version.rb +1 -1
  136. data/lib/rails_ai_context/vfs.rb +1 -1
  137. data/lib/rails_ai_context.rb +8 -0
  138. data/server.json +1 -1
  139. metadata +24 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 175ccbf5bac4703a8f9699708a02435a9b80546c09129deae79ffb5f182e14e6
4
- data.tar.gz: 0adbdf439e6ac853606fc786d6f92041a70aec4e2379ebfe98132eb14352b219
3
+ metadata.gz: 5e92439f72b8f723d75c6b62ca2fe3734f29b60548e50d2ddea836c93a66d952
4
+ data.tar.gz: c541d140c1f7971fa0f62d5e664673a338ffc7e809b8df8578035890224567d0
5
5
  SHA512:
6
- metadata.gz: 9bb08e8b5d06124360be6870c7f667f008a666d07bea55a33b89662049ea1ee00b3ab88b5c5060ee70c47b8d1b77c422cfd8434f3327094427e2ac321c8a9201
7
- data.tar.gz: 160c8b647c1a6be4bec7290be0308a98318edd92c2525235410bd1fed94459ddd107538f5ced4c8fced29745a9de00aa8dfc37f0bdc9274afa358c0aa2f82e3f
6
+ metadata.gz: e2e550d201dd7af30d4e95e5ec368d2f376d2b8812d5254ffcf10ab8f78b1f0e089df5ba5f2664fbe858dbf2a5277c15e2489df0e86b61b22ebd44868560854f
7
+ data.tar.gz: 80daba010490cd5b6d1e0c4e770582b4bda8db4d073ebb9bead8d2eb3f015ec966c1ce1fef60f229755e1f1610b48b2287da480d790f20ed3be705d8b4ee95d7
data/CHANGELOG.md CHANGED
@@ -5,6 +5,134 @@ 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.20.0] - 2026-08-11
9
+
10
+ ### Security
11
+
12
+ - **Initializer config values are redacted where they are read, not where
13
+ they are rendered.** The config-assignment listener served raw source
14
+ slices, so a `config.secret_key = "..."` in `devise.rb` reached
15
+ `rails_get_context` and the generated context files in plaintext. Values
16
+ assigned to secret-named settings are now filtered at emission, which
17
+ covers every current and future reader of that listener.
18
+ - **Redaction and shortening are one operation.** They were separate calls
19
+ in the caller's hands, and getting the order wrong let a long credential
20
+ be cut apart before the pattern that would have caught it ever ran. The
21
+ module exposes `redact_and_shorten`, so the order is not a caller's to get
22
+ wrong.
23
+ - **The secret vocabulary widens, and nesting no longer hides a value.**
24
+ `pepper`, `salt`, `master_key`, `signing_key`, `encryption_key`,
25
+ `deterministic_key` and `encryption.primary_key` now read as secret names,
26
+ and a value under a secret-named setting is filtered however deeply it
27
+ nests. `config.secret = { primary: [ "..." ] }` emitted the credential in
28
+ full.
29
+
30
+ ### Changed
31
+
32
+ - **The prism floor rises from 0.28 to 1.4.** The old range was a claim
33
+ nothing tested. A CI leg now pins prism at exactly 1.4.0 on Ruby 3.2, where
34
+ prism is a real gem rather than stdlib, with a trimmed gemfile so a dev
35
+ dependency cannot resolve it upward and turn the floor into a test of
36
+ whatever version won. A scheduled allowed-to-fail leg runs the suite
37
+ against prism's main branch, so a removal there surfaces here rather than
38
+ in your bundle. 0.28 was measured green in the one configuration that could
39
+ be checked locally; the raise is a tightening to a version CI proves, not a
40
+ fix for a known break.
41
+ - **Redaction markers converge on `[FILTERED]`.** One module now owns the
42
+ patterns and the vocabulary. `[EMAIL]` stays as the one semantic marker.
43
+ Marker presence and this vocabulary are contract going forward; see the
44
+ output contract in `docs/SECURITY.md`.
45
+
46
+ | Was | Now |
47
+ |-----|-----|
48
+ | `[REDACTED]` | `[FILTERED]` |
49
+ | `[redacted]` | `[FILTERED]` |
50
+ | `[ENV VAR REDACTED]` | `[FILTERED]` |
51
+ | `[dotenv] Set [ENV VARS REDACTED]` | `[dotenv] Set [FILTERED]` |
52
+ | `[EMAIL]` | `[EMAIL]` (unchanged) |
53
+
54
+ - **`rails_get_schema` says "failed:" where it used to say "not available:"**
55
+ when the introspector raised. It was the only one of the sixteen tools
56
+ carrying that preamble whose failed-case wording differed; it now shares
57
+ the phrasing the other fifteen use. The not-available and unavailable
58
+ answers are unchanged.
59
+ - **An invalid `detail` answers at the default level and says it did.**
60
+ Eleven tools each answered `Unknown detail level: x` and nothing else;
61
+ `detail` is normalized once now, before any tool runs, so junk and omission
62
+ both land on the default. You still get told: the response carries a note
63
+ naming the value that was discarded and the levels that exist, so a typo is
64
+ visible without eleven copies of the check. Tools that spell their own
65
+ levels (`rails_onboard`: quick/standard/full) are untouched.
66
+ - **Listener registration is derived from the listener.** Defining an `on_*`
67
+ handler is now its registration, validated against the events the running
68
+ prism dispatches, so a typo'd handler raises instead of never firing. The
69
+ hand-typed event allowlist that shipped the 5.19.1 fabricated-routes bug is
70
+ gone, along with the hydrator's private copy of it.
71
+ - **MCP view resources resolve through the current app root** rather than
72
+ `Rails.root`, so `rails-ai-context://views/...` works in the static tier.
73
+ - **One error frame for the MCP edge.** The middleware and the engine
74
+ controller each built their own JSON-RPC internal-error body; both now read
75
+ it from one place, so a failure looks the same whichever transport served
76
+ it. Transport construction moves behind one factory; memoization stays with
77
+ each caller, which holds a different scope (per instance, per class, per
78
+ process).
79
+ - **A setting's name decides whether it is redacted, not its value's type.**
80
+ `config.secret_key = 12345` was filtered in one emitted field and left plain
81
+ in the other, because one path saw an Integer and the other saw the source
82
+ text. One decision drives both now.
83
+
84
+ ### Added
85
+
86
+ - **Twenty-seven sections now answer with the app not booted.** Gems, i18n,
87
+ views, view templates, tests, jobs, turbo, stimulus, assets, devops, seeds,
88
+ middleware, engines, components, performance, credentials, env and the rest
89
+ read files the static tier already had on disk; they refused only because
90
+ nothing had said they could answer. Each introspector now declares its
91
+ static tier (files-only, alternate-source, or runtime-only), an undeclared
92
+ one fails the suite, and every files-only declaration is proven against a
93
+ fixture app with nothing booted. Runtime-only sections - config, api,
94
+ conventions, autoload, security, observability, database stats, connection
95
+ pool, initializers - still refuse honestly.
96
+
97
+ ### Fixed
98
+
99
+ - **Re-running install through a different entry keeps your AI-tool
100
+ selection.** The Rails generator read it from the initializer, the
101
+ standalone CLI read it from `.rails-ai-context.yml`, and the rake task
102
+ kept a third copy of the logic. Switching between them silently dropped
103
+ the previous choice and re-prompted from scratch. All three read and write
104
+ the same record now, initializer first on read because that is the file you
105
+ hand-edit. Recording the selection also reaches both files together, so an
106
+ entry can no longer leave a stale initializer that outranks the YAML it
107
+ just wrote. An initializer with a `configure` block but no selection line
108
+ now gets one from any entry, not just the rake task.
109
+ - **The standalone HTTP server scopes its session record per client.** The
110
+ Rack middleware and the engine controller got this; `rails-ai-context serve
111
+ --transport http` serves many clients from one process too and was still
112
+ pooling their `rails_session_context` history.
113
+ - **The generated guide advertised a CLI command that does not exist.** Its
114
+ row for `rails_get_env_config` printed `ai:tool[environments]`, which the
115
+ CLI cannot resolve; the CLI name is now derived from the tool name rather
116
+ than typed beside it.
117
+ - **One MCP client's session record no longer shows up in another's.**
118
+ `rails_session_context` kept one process-global list of calls, which is
119
+ right over stdio but wrong for the two HTTP transports, where a single
120
+ process serves every client. The record is now bucketed by
121
+ `Mcp-Session-Id`. A caller's snapshot of the record also stopped changing
122
+ under it: `session_queries` returned live entries that later calls kept
123
+ mutating.
124
+ - **`.env.example` placeholders are shown, not filtered.** A
125
+ `<your-secret-here>` came back as `[FILTERED]`, hiding the one thing an
126
+ example file exists to show. A credential's shape and a placeholder's are
127
+ told apart now.
128
+ - **The HTTP session record evicts the least recently used client, and is
129
+ bounded.** It dropped the oldest-created session, which is usually the
130
+ busiest one still in use, and it grew for as long as the process lived.
131
+ - **Tools no longer report themselves as failing on mcp 0.8.** The note
132
+ naming a discarded `detail` value read `meta` off the response, which the
133
+ 0.8 SDK has no reader for, so building the note raised and the tool
134
+ answered `Tool ... failed:`.
135
+
8
136
  ## [5.19.1] - 2026-08-10
9
137
 
10
138
  ### Fixed
data/CONTEXT.md CHANGED
@@ -12,6 +12,22 @@ Overloaded. Always qualify it; never use "environment" bare in a tool name, an i
12
12
 
13
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
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 inside a Rails app, read initializer-first because that is the file a user hand-edits.
22
+
15
23
  ## Static tier
16
24
 
17
- The mode where the app did not boot, or `--no-boot` was passed. An introspector answers in this tier only if it defines `static_call`. Two shapes count as defining it: reading a different source (parsing `db/schema.rb` instead of querying the connection), and reading the same source a booted app would (a file-based introspector, where `static_call` is the same work under another name).
25
+ 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:
26
+
27
+ **files-only** - `call` runs unchanged against the static app handle, because it only ever read files. Most of the map.
28
+
29
+ **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.
30
+
31
+ **runtime-only** - needs `app.config`, `app.routes` or a live database, and refuses honestly.
32
+
33
+ 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.
data/CONTRIBUTING.md CHANGED
@@ -33,16 +33,18 @@ lib/rails_ai_context/
33
33
  1. Create `lib/rails_ai_context/introspectors/your_introspector.rb` (auto-loaded by Zeitwerk)
34
34
  2. Implement `#initialize(app)` and `#call` → returns a Hash (never raises)
35
35
  3. Register it in `lib/rails_ai_context/introspector.rb` (the `INTROSPECTOR_MAP`)
36
- 4. Add the key to the appropriate preset(s) in `Configuration::PRESETS` (`:full` is the default, `:standard` for core-only)
37
- 5. Write specs in `spec/lib/rails_ai_context/your_introspector_spec.rb`
36
+ 4. `extend StaticTier` and declare one: `static_tier :files_only` if `#call` runs unchanged with nothing booted, `:runtime_only` if it needs live reflection, or `:alternate_source` plus a `static_call` reading elsewhere. A mapped introspector that declares nothing fails the suite
37
+ 5. Add the key to the appropriate preset(s) in `Configuration::PRESETS` (`:full` is the default, `:standard` for core-only)
38
+ 6. Write specs in `spec/lib/rails_ai_context/your_introspector_spec.rb`
38
39
 
39
40
  ## Adding a New MCP Tool
40
41
 
41
42
  1. Create `lib/rails_ai_context/tools/your_tool.rb` inheriting from `BaseTool` (auto-loaded by Zeitwerk)
42
43
  2. Define `tool_name`, `description`, `input_schema`, and `annotations`
43
44
  3. Implement `def self.call(...)` returning `text_response(string)`
44
- 4. Auto-registered - no manual list to update (BaseTool.inherited tracks it)
45
- 5. Write specs in `spec/lib/rails_ai_context/tools/your_tool_spec.rb`
45
+ 4. Declare `guide_row(order:, mcp:, cli_args:, summary:)` - the generated guide is derived from it. A tool without one fails the inventory spec, and `order` must be unique and leave no gap in the sequence
46
+ 5. Auto-registered - no manual list to update (BaseTool.inherited tracks it)
47
+ 6. Write specs in `spec/lib/rails_ai_context/tools/your_tool_spec.rb`
46
48
 
47
49
  ## Adding a Prism Listener
48
50
 
@@ -52,7 +54,7 @@ Listeners extract specific concerns (associations, validations, etc.) from the A
52
54
  2. Implement `on_call_node_enter(node)` and/or `on_def_node_enter(node)` - only the events your concern needs
53
55
  3. Use `confidence_for(node)` from `BaseListener` to tag results `[VERIFIED]` or `[INFERRED]`
54
56
  4. Store results in `@results` (accessed via `#results`) as plain hashes, never Prism nodes
55
- 5. If your listener needs an event `SourceIntrospector.register_listener` doesn't already wire up, add it there
57
+ 5. Nothing to register - `ListenerRegistration` derives the events from the `on_*` methods you defined, and raises if one names an event prism never dispatches
56
58
  6. Register the key/class pair in `SourceIntrospector::LISTENER_MAP` only if the listener should run on every model walk; listeners used by one introspector are passed to `SourceIntrospector.walk(path, key => Listener)` at the call site instead
57
59
  7. Write specs in `spec/lib/rails_ai_context/introspectors/listeners/your_listener_spec.rb`
58
60
  8. Add a row to the listener catalogue in `docs/INTROSPECTORS.md`
@@ -27,6 +27,32 @@ module RailsAiContext
27
27
  end
28
28
 
29
29
  def handle
30
+ Tools::BaseTool.with_session_for(request.env) do
31
+ serve_transport
32
+ end
33
+ rescue => e
34
+ # Once the response is committed the status and headers are already on
35
+ # the wire, so a JSON-RPC frame written here cannot reach the client.
36
+ # Worse, assigning a body swaps the stream out from under the thread
37
+ # still draining the old one, turning a truncated SSE stream into a
38
+ # garbled one. The streaming branch's ensure always closes the stream,
39
+ # and closing commits, so every streaming failure lands here committed.
40
+ # Hand those to Live, which logs them with a backtrace and tears the
41
+ # connection down.
42
+ raise if response.committed?
43
+
44
+ # A transport failure must still answer in JSON-RPC shape. Without this
45
+ # the exception escapes into a generic Rails 500 (HTML), breaking the
46
+ # client's JSON-RPC loop.
47
+ RailsAiContext.log_warn "[rails-ai-context] MCP request failed: #{e.class}: #{e.message}"
48
+ self.status = 500
49
+ response.headers["Content-Type"] = "application/json"
50
+ self.response_body = McpEdge.internal_error_frame(e)
51
+ end
52
+
53
+ private
54
+
55
+ def serve_transport
30
56
  status_code, rack_headers, body = self.class.mcp_transport.handle_request(request)
31
57
  self.status = status_code
32
58
  apply_transport_headers(rack_headers)
@@ -67,32 +93,8 @@ module RailsAiContext
67
93
  else
68
94
  self.response_body = body
69
95
  end
70
- rescue => e
71
- # Once the response is committed the status and headers are already on
72
- # the wire, so a JSON-RPC frame written here cannot reach the client.
73
- # Worse, assigning a body swaps the stream out from under the thread
74
- # still draining the old one, turning a truncated SSE stream into a
75
- # garbled one. The streaming branch's ensure always closes the stream,
76
- # and closing commits, so every streaming failure lands here committed.
77
- # Hand those to Live, which logs them with a backtrace and tears the
78
- # connection down.
79
- raise if response.committed?
80
-
81
- # Mirror Middleware#json_rpc_error_response: a transport failure must
82
- # still answer in JSON-RPC shape. Without this the exception escapes
83
- # into a generic Rails 500 (HTML), breaking the client's JSON-RPC loop.
84
- RailsAiContext.log_warn "[rails-ai-context] MCP request failed: #{e.class}: #{e.message}"
85
- self.status = 500
86
- response.headers["Content-Type"] = "application/json"
87
- self.response_body = {
88
- jsonrpc: "2.0",
89
- error: { code: -32603, message: "Internal error: #{e.message}" },
90
- id: nil
91
- }.to_json
92
96
  end
93
97
 
94
- private
95
-
96
98
  # Rack 3 transports name their headers in lowercase, and the MCP SDK
97
99
  # switched to that in 1.0. Rails 7.0 keeps response headers in a
98
100
  # case-sensitive Hash, so a lowercase "content-type" is invisible to the
@@ -131,12 +133,7 @@ module RailsAiContext
131
133
  # Class-level memoization - transport persists across requests.
132
134
  # Thread-safe: MCP::Server and transport are stateless for reads.
133
135
  def mcp_transport
134
- @transport_mutex.synchronize do
135
- @mcp_transport ||= begin
136
- server = RailsAiContext::Server.new(Rails.application, transport: :http).build
137
- MCP::Server::Transports::StreamableHTTPTransport.new(server)
138
- end
139
- end
136
+ @transport_mutex.synchronize { @mcp_transport ||= McpEdge.build_transport }
140
137
  end
141
138
 
142
139
  def reset_transport!
@@ -19,7 +19,7 @@ document is aspirational.
19
19
  - **Rails (railties):** 7.0 - 8.1 (gemspec: `railties >= 7.0, < 9.0`)
20
20
  - **mcp gem:** `>= 0.8, < 2.0`
21
21
  - **thor:** `>= 1.0, < 3.0`
22
- - **prism:** `>= 0.28, < 2.0`
22
+ - **prism:** `>= 1.4, < 2.0` (a CI leg pins the floor exactly and runs the suite against it)
23
23
  - **concurrent-ruby:** `>= 1.2, < 3.0`
24
24
 
25
25
  The gemspec's `railties` bound is wider than the CI matrix: point releases inside
@@ -214,7 +214,7 @@ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file
214
214
 
215
215
  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`.
216
216
  2. Implement the `on_*_node_enter` hooks you need and push plain hashes onto `@results`. Never return Prism nodes as the result itself; `option_nodes` is the one deliberate exception, for callers that must inspect an expression's shape.
217
- 3. Register the event in `SourceIntrospector.register_listener` if it is not already in the list (call, def, class, module, block, singleton class, case, constant write).
217
+ 3. Nothing to register. `ListenerRegistration` reads the events off the `on_*` methods you defined, inherited ones included, and raises if one names an event prism never dispatches.
218
218
  4. Add a spec of the same name under `spec/lib/rails_ai_context/introspectors/listeners/`.
219
219
  5. Add a row to the table above.
220
220
 
data/docs/SECURITY.md CHANGED
@@ -190,14 +190,45 @@ Complex patterns that would cause catastrophic backtracking raise `RegexpError`
190
190
 
191
191
  ---
192
192
 
193
- ## Log redaction
193
+ ## Redaction
194
194
 
195
- The `rails_read_logs` tool redacts sensitive data from log output:
195
+ Everything that leaves your app through this gem - log lines, query rows, environment values, config source slices - passes through one redaction module before anything else can touch it. Redacting and shortening are a single operation there, so a long credential cannot be cut apart in a way that hides it from the pattern that would have caught it.
196
196
 
197
- - Passwords and tokens
198
- - Email addresses
199
- - Secret values
200
- - API keys
197
+ What gets redacted:
198
+
199
+ - Passwords, tokens, secrets and API keys, wherever they are named
200
+ - Credentials embedded in URIs (`redis://user:pass@host`)
201
+ - Email addresses in log lines
202
+ - Values assigned to secret-named settings in initializers
203
+
204
+ ### Markers
205
+
206
+ Redacted values carry one of two markers, and only these two:
207
+
208
+ | Marker | Means |
209
+ |--------|-------|
210
+ | `[FILTERED]` | A value was removed because it is or may be a secret |
211
+ | `[EMAIL]` | An email address was removed |
212
+
213
+ Marker presence and this vocabulary are part of the output contract (see below) - you can pattern-match on them.
214
+
215
+ ---
216
+
217
+ ## Output contract
218
+
219
+ **Contract, safe to depend on:**
220
+
221
+ - Tool names and their input schemas
222
+ - The presence of a redaction marker wherever a value was removed
223
+ - The `[FILTERED]` / `[EMAIL]` marker vocabulary
224
+
225
+ **Incidental, expected to change between releases:**
226
+
227
+ - Response text, headings and formatting
228
+ - Refusal and unavailability wording
229
+ - Row and section ordering
230
+
231
+ Pin your assertions to the first list. When something in the second list changes in a way that could break a pinned assertion, the CHANGELOG says so.
201
232
 
202
233
  ---
203
234
 
data/docs/SETUP.md CHANGED
@@ -298,7 +298,7 @@ Type in Claude Code's prompt:
298
298
  What MCP tools do you have access to?
299
299
  ```
300
300
 
301
- You should see `rails_get_schema`, `rails_search_code`, and the other 43 tools listed.
301
+ You should see `rails_get_schema`, `rails_search_code`, and the rest of the tools listed.
302
302
 
303
303
  ### Cursor
304
304
 
@@ -188,7 +188,7 @@ Common false positives:
188
188
  - Hash characters in strings → write the hash outside a comment position
189
189
  - JSONB operators (`#>>`) → preserved correctly since v5.6.0
190
190
 
191
- ### "Column values show [REDACTED]"
191
+ ### "Column values show [FILTERED]"
192
192
 
193
193
  Columns matching sensitive patterns are redacted. Configure:
194
194
 
@@ -0,0 +1,9 @@
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.
@@ -0,0 +1,5 @@
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`.
@@ -0,0 +1,23 @@
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
+ **`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.
12
+
13
+ **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`.
14
+
15
+ 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.
16
+
17
+ ## Intended semantics, not defects
18
+
19
+ **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.
20
+
21
+ **`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.
22
+
23
+ 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.
@@ -0,0 +1,28 @@
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.