rails-ai-context 5.7.1 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +2 -2
  4. data/docs/CONFIGURATION.md +1 -0
  5. data/docs/FAQ.md +1 -1
  6. data/docs/GUIDE.md +4 -0
  7. data/docs/STANDALONE.md +3 -0
  8. data/docs/TOOLS.md +1 -1
  9. data/docs/social-preview.html +1 -1
  10. data/exe/rails-ai-context +44 -6
  11. data/lib/generators/rails_ai_context/install/install_generator.rb +4 -0
  12. data/lib/rails_ai_context/configuration.rb +34 -3
  13. data/lib/rails_ai_context/fingerprinter.rb +34 -7
  14. data/lib/rails_ai_context/instrumentation.rb +46 -3
  15. data/lib/rails_ai_context/introspectors/auth_introspector.rb +62 -4
  16. data/lib/rails_ai_context/introspectors/convention_introspector.rb +26 -0
  17. data/lib/rails_ai_context/introspectors/gem_introspector.rb +1 -0
  18. data/lib/rails_ai_context/introspectors/job_introspector.rb +93 -3
  19. data/lib/rails_ai_context/introspectors/model_introspector.rb +2 -1
  20. data/lib/rails_ai_context/introspectors/schema_introspector.rb +15 -1
  21. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +5 -1
  22. data/lib/rails_ai_context/tools/analyze_feature.rb +45 -19
  23. data/lib/rails_ai_context/tools/base_tool.rb +20 -23
  24. data/lib/rails_ai_context/tools/get_edit_context.rb +18 -3
  25. data/lib/rails_ai_context/tools/get_job_pattern.rb +69 -12
  26. data/lib/rails_ai_context/tools/get_partial_interface.rb +16 -5
  27. data/lib/rails_ai_context/tools/get_view.rb +24 -5
  28. data/lib/rails_ai_context/tools/migration_advisor.rb +68 -0
  29. data/lib/rails_ai_context/tools/query.rb +100 -0
  30. data/lib/rails_ai_context/tools/search_code.rb +1 -1
  31. data/lib/rails_ai_context/tools/validate.rb +33 -6
  32. data/lib/rails_ai_context/version.rb +1 -1
  33. data/lib/rails_ai_context/vfs.rb +24 -5
  34. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e613f50465df28b1b7830acae8a75665fb6c5ce1fc2058e9c4e33f0dbc7e5e47
4
- data.tar.gz: cc04cd3316cba5728ee08c5324ee546aedf60eea9dd656aa942754edccbbb58d
3
+ metadata.gz: 8b8c8f1c7133f9f97b35975be1ecf1485e6fd663867c7fb6ae776af131f972ec
4
+ data.tar.gz: 2a4e374b3509f1eca37ddeefa730eb8fc181a7710df616c7a0ab389babce7280
5
5
  SHA512:
6
- metadata.gz: d6950c03b5b7ad58f2ae1df320d698e82dc244976b1140b19fe8c0aad9a37b7430db2504d990a9bb794fc2278ff591e4416b68c0c6027df7df6001198d5fe55f
7
- data.tar.gz: e80e37aba8ea2602a9142490c69fb35fa574669e234cf9b7c71093846d57687b7c4b355dbdf3fd295459e74b0b4e19fc4316cb69fc009608c8e28b19e9558409
6
+ metadata.gz: a6c3bed1bd469fbee71f6395ad2f7ac0fc90347fdf2bf8066f36f8396a99a915f5954aa207207cabf9b073c435e2fbe7849692e28ba2c0ab752ef516cc8b32e0
7
+ data.tar.gz: 16791ed1aa9293f5d87d61bd1a63c3fca990cdf25da6f06639ba514912e097f69a177f9e829b14684fbe2242b8ab53f4828f5b2ad7c15f485a1e5c2493152c4b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,103 @@ 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.8.1] — 2026-04-15
9
+
10
+ ### Fixed — Security Hardening
11
+
12
+ Four exploitable vulnerabilities across `rails_query`, the VFS URI dispatcher, and the instrumentation bridge, plus six defense-in-depth hardening issues. All discovered by security and deep code-review passes conducted during v5.8.1 pre-release verification. None were known at the v5.8.0 release — **users should upgrade immediately**.
13
+
14
+ - **SQL column-aliasing redaction bypass (exploitable).** Post-execution redaction in `rails_query` operated on `result.columns` (the DB-returned column names), which the caller controls via aliases and expressions. `SELECT password_digest AS x FROM users` returned raw bcrypt hashes. Same for `SELECT substring(password_digest, 1, 60) FROM users` (column named `substring`), `SELECT md5(session_data) FROM sessions`, `SELECT CASE WHEN id > 0 THEN password_digest END FROM users`, and subqueries that re-project the sensitive column. **Fix:** moved enforcement to pre-execution in `validate_sql`. Any query that textually references a column name in `config.query_redacted_columns` OR the hard-coded `SENSITIVE_COLUMN_SUFFIXES` list (password_digest, encrypted_password, password_hash, reset_password_token, api_key, refresh_token, otp_secret, session_data, secret_key, private_key, etc.) is now rejected. Users with a legitimately non-sensitive column matching one of these names can subtract from `config.query_redacted_columns` in an initializer. **8 bypass scenarios covered by new specs.**
15
+
16
+ - **Arbitrary filesystem read via database functions (exploitable).** `rails_query` did not block PostgreSQL's `pg_read_file`, `pg_read_binary_file`, `pg_ls_dir`, `pg_stat_file`, `lo_import`/`lo_export`, `dblink`, MySQL's `LOAD_FILE`, `SELECT ... INTO OUTFILE/DUMPFILE`, or SQLite's `load_extension`. These are SELECT-callable (so they pass the `BLOCKED_KEYWORDS` scanner and `SET TRANSACTION READ ONLY`) but give the caller a filesystem and shared-library-load primitive — completely bypassing the gem's `sensitive_patterns` allowlist by pivoting through the database process. PoC: `SELECT pg_read_file('/etc/passwd')`, `SELECT pg_read_file('config/master.key')`. **Fix:** added a `BLOCKED_FUNCTIONS` regex and `BLOCKED_OUTPUT` pattern that reject any query referencing these built-ins. **10 function-specific specs added.**
17
+
18
+ - **`sensitive_patterns` default list expanded.** The v5.8.0 default list covered `.env`, `.env.*`, `config/master.key`, `config/credentials*.yml.enc`, `*.pem`, `*.key` but missed common secret locations. v5.8.1 adds `config/database.yml`, `config/secrets.yml`, `config/cable.yml`, `config/storage.yml`, `config/mongoid.yml`, `config/redis.yml`, `*.p12`, `*.pfx`, `*.jks`, `*.keystore`, `**/id_rsa`, `**/id_ed25519`, `**/id_ecdsa`, `**/id_dsa`, `.ssh/*`, `.aws/credentials`, `.aws/config`, `.netrc`, `.pgpass`, `.my.cnf`.
19
+
20
+ - **`get_edit_context` now re-checks `sensitive_file?` after realpath resolution.** The initial check ran on the caller-supplied string; a symlink inside `app/models/` pointing at `config/master.key` previously passed the basename check and fell through to `File.read`. The post-realpath check blocks this.
21
+
22
+ - **`validate` now enforces `sensitive_file?`.** The validate tool had no sensitive-file check at all. Even though its output is limited to error messages (not raw content), it still leaked file existence/size and ran readers on secret files. Now denied with an `access denied (sensitive file)` error.
23
+
24
+ - **`BaseTool.sensitive_file?` has direct spec coverage for the first time.** The security boundary behind every file-accepting tool had zero direct tests in v5.8.0 — 36 new specs added covering the Rails secret locations, the v5.8.1 expanded pattern list, private keys and certificates, case-insensitivity, basename-only matching, and custom pattern configurations.
25
+
26
+ - **VFS `resolve_view` sibling-directory path traversal (exploitable).** The `rails-ai-context://views/{path}` URI resolver used bare `String#start_with?` on the realpath without a `File::SEPARATOR` suffix check. `/app/views_spec/secret.erb` matched `/app/views` as a prefix, so a symlink inside `app/views/` pointing at a sibling directory escaped containment and returned arbitrary file content. **Fix:** changed the containment check to `real == base || real.start_with?(base + File::SEPARATOR)`. Also added a `sensitive_file?` realpath check mirroring the v5.8.1 `get_edit_context` fix, so `.env`/`.key` symlinks inside `app/views/` are rejected. **2 new regression specs covering both PoCs.**
27
+
28
+ - **Instrumentation bridge leaks raw tool arguments to ActiveSupport::Notifications subscribers (exploitable).** `Instrumentation.callback` forwarded the MCP SDK's full data hash to `ActiveSupport::Notifications.instrument`. The SDK's `add_instrumentation_data(tool_name:, tool_arguments:)` includes raw tool inputs — so every Rails observability subscriber (Datadog, Scout, New Relic, custom loggers) received `rails_query`'s raw SQL, `rails_get_env`'s env var names, and `rails_read_logs`'s search patterns unredacted. The response-side redaction each of those tools carefully implements did nothing for the request side. **Fix:** introduced `Instrumentation::SAFE_KEYS` (`method`, `tool_name`, `duration`, `error`, `resource_uri`, `prompt_name`) — only those fields are forwarded. Users who need arguments in observability can set `config.instrumentation_include_arguments = true` in an initializer (taking on the redaction obligation). **3 new regression specs.**
29
+
30
+ - **Instrumentation subscriber failures could crash tool calls (exploitable).** The MCP SDK's `instrument_call` invokes our callback from an `ensure` block. Any exception raised inside the callback (e.g. a custom subscriber bug, a Datadog client losing connection) would propagate out of `ensure` and overwrite the tool's actual return value — effectively failing every tool call whenever any subscriber was broken. **Fix:** wrapped the `Notifications.instrument` call in a `rescue => e` block. Subscriber failures now log to stderr under `DEBUG=1` instead of corrupting tool responses. **1 new regression spec.**
31
+
32
+ - **`analyze_feature` caps per-directory file scans at 500 files.** `discover_services`, `discover_jobs`, and `discover_views` previously ran unbounded `Dir.glob` + `SafeFile.read` on every match, which on large monorepos could read thousands of files per call. Matches the existing cap used by `discover_tests`. Tool output notes when the cap was hit so the AI agent knows to narrow its feature keyword.
33
+
34
+ ### Added — Configuration
35
+
36
+ - **`config.instrumentation_include_arguments`** (default `false`) — controls whether raw tool arguments are forwarded to `ActiveSupport::Notifications` subscribers. See the Security Hardening note above for the opt-in risk.
37
+
38
+ ### Performance — Hot-Path Optimization
39
+
40
+ - **`cached_context` TTL short-circuit.** The hot path of every tool call ran `Fingerprinter.changed?` on every hit, which walks every `*.{rb,rake,js,ts,erb,haml,slim,yml}` file in `WATCHED_DIRS` plus (for path:-installed users) every file in the gem's own lib/ tree — doing an `mtime` stat per file. Measured at ~12ms per call in dev-mode path installs, ~0.5ms in production. Since LiveReload fires `reset_all_caches!` on actual file-change events, stale-cache risk during a short TTL window is already covered. **Fix:** skip the fingerprint check entirely when within the TTL window. When TTL expires, re-fingerprint; if unchanged, bump the timestamp and reuse the cached context (avoiding a 31-introspector re-run).
41
+
42
+ - **Fingerprinter gem-lib scan memoized.** For users who install the gem via `path:` (common for gem contributors, monorepos, the standalone dev workflow), the fingerprinter was walking 123 gem-lib files on every tool call. Memoized at class level with a `reset_gem_lib_fingerprint!` hook that `BaseTool.reset_cache!` and LiveReload invoke.
43
+
44
+ - **Measured result:** `cached_context` hot-path benchmark dropped from **11.77ms to 0.199ms** per call — a **~59x speedup** on dev-mode path installs. In-Gemfile / production users see a smaller but still meaningful improvement (0.77ms → 0.199ms).
45
+
46
+ ### Fixed — schema.rb empty-file wrinkle
47
+
48
+ - `SchemaIntrospector#static_schema_parse` returned `{ error: "No db/schema.rb, db/structure.sql, or migrations found" }` when `db/schema.rb` existed but contained zero `create_table` calls (common on freshly-created Rails apps between `db:create` and the first migration). Now returns `{ total_tables: 0, tables: {}, note: "Schema file exists but is empty — no migrations have been run yet..." }`.
49
+
50
+ ### Changed — CI release matrix synced to PR matrix
51
+
52
+ - `.github/workflows/release.yml` test matrix was still on the old Ruby `3.2/3.3/3.4` × Rails `7.1/7.2/8.0` grid even though `ci.yml` was expanded to cover Ruby 4.0 and Rails 8.1 in v5.8.0. Now synced — release-time testing matches PR-time testing across all 12 combos, including the #69 reporter's environment (Ruby 4.0.2 + Rails 8.1.3).
53
+
54
+ ### Fixed — Pre-release review pass (rounds 2–3)
55
+
56
+ Five additional issues found during multi-round cold-eyes security and correctness review after the initial hardening pass.
57
+
58
+ - **`search_code` sibling-directory path traversal.** `rails_search_code`'s `path` parameter used `real_search.start_with?(real_root)` without a `File::SEPARATOR` suffix — the same bypass class as the original VFS C1 bug. A Rails root of `/app/myapp` would accept a search path whose realpath is `/app/myapp_evil`. **Fix:** changed to `real_search == real_root || real_search.start_with?(real_root + File::SEPARATOR)`. Spec added.
59
+
60
+ - **Instrumentation callback: `data[:method]` extraction outside `begin/rescue`.** Two lines before the `begin` block (`method = data[:method]` and `event_name = ...`) were not covered by the rescue. A non-Hash `data` argument from the MCP SDK would raise `NoMethodError` which would propagate into the SDK's `ensure` context and overwrite the tool's return value. **Fix:** moved `begin` to wrap the full lambda body after the early-exit guard.
61
+
62
+ - **`get_partial_interface` TOCTOU gap (residual from initial hardening).** `resolve_partial_path` performed the `File.realpath` security check internally but returned the original glob `found` path to the caller. The caller then called `File.size(found)` and `safe_read(found)` — creating a sub-millisecond race window where a symlink swap could read from a path that bypassed the check. **Fix:** `resolve_partial_path` now returns `real_found`. All file operations in the caller use the pre-checked realpath.
63
+
64
+ - **`validate` tool passed pre-realpath path to validators.** `validate_ruby`, `validate_erb`, `validate_javascript`, and `check_rails_semantics` all received `full_path` (pre-realpath) after the security check resolved `real`. **Fix:** all four now receive `Pathname.new(real)`.
65
+
66
+ - **`rails_query` `LOAD DATA INFILE` not explicitly blocked.** Added `LOAD\s+DATA` to `BLOCKED_FUNCTIONS`. Belt-and-suspenders: `ALLOWED_PREFIX` already blocks it at statement level, but the explicit entry makes intent self-documenting. Two specs added (`LOAD DATA INFILE` and `LOAD DATA LOCAL INFILE`).
67
+
68
+ ### Test coverage
69
+
70
+ - **2004 examples, 0 failures** (was 1928 in v5.8.0, +76 new regression tests across the security + hardening + empty-schema + VFS + instrumentation + review-pass fixes).
71
+
72
+ ## [5.8.0] — 2026-04-14
73
+
74
+ ### Added — Modern Rails Coverage Pass
75
+
76
+ Five targeted gaps in modern Rails introspection, identified by an audit of the introspectors against current Rails 7/8 patterns. Net result: the gem now surfaces what AI agents need to know about Rails 8 built-in auth, Solid Errors, async query usage, strong_migrations safety, and Action Cable channel detail.
77
+
78
+ - **Rails 8 built-in auth depth.** `auth_introspector#detect_authentication` previously detected `bin/rails generate authentication` only as a boolean. Now returns a hash with the Authentication concern path, the Sessions/Passwords controller paths, and a per-controller list of `allow_unauthenticated_access` filters with their `only:`/`except:` scope. Each declaration in a file yields its own entry (a controller with both `only:` and `except:` is captured fully, not collapsed to the first match), and trailing line comments are stripped from the captured scope. AI agents can answer "which controllers are public?" in one tool call.
79
+ - **Solid Errors gem detection.** Added `solid_errors` (Rails 8 database-backed error tracking, by @fractaledmind) to `gem_introspector.rb`'s `NOTABLE_GEMS` map under the `:monitoring` category. Was the only Solid-* gem missing from the list (`solid_queue`, `solid_cache`, `solid_cable` were already covered). `solid_health` is NOT a real published gem — Rails 8 ships a built-in `/up` healthcheck endpoint with no gem needed.
80
+ - **Async query pattern detection.** `convention_introspector#detect_patterns` now adds `async_queries` to the patterns array when it finds `load_async` or any of the `async_count`/`async_sum`/`async_minimum`/`async_maximum`/`async_average`/`async_pluck`/`async_ids`/`async_exists`/`async_find_by`/`async_find`/`async_first`/`async_last`/`async_take` calls in `app/controllers`, `app/services`, `app/jobs`, or `app/models`. Comment-only references (e.g. `# TODO: bring back load_async`) are skipped to avoid false positives. AI agents can recognize the perf optimization is in use without re-scanning.
81
+ - **Strong Migrations integration.** `migration_advisor` now emits a `## Strong Migrations Warnings` section when the `strong_migrations` gem is in `Gemfile.lock`. Catalog covers the most common breaking-change patterns: `remove_column` (needs `safety_assured` + `ignored_columns` first), `rename_column` (unsafe under load, two-step pattern), `change_column` type change (table rewrite), `add_index` without `algorithm: :concurrently` (Postgres write lock), `add_foreign_key` without `validate: false` (lock validation), and `add_column` with `null: false` but no default (table rewrite). Each warning includes the safer pattern. Fires only when the gem is detected — zero noise for projects that don't use it.
82
+ - **Action Cable channel detail.** `job_introspector#extract_channels` was returning `{ name, stream_methods }` only. Enriched to also extract `identified_by` attributes, `stream_from`/`stream_for` targets, `periodically` timers with their full intervals (including lambdas like `every: -> { current_user.interval }`), RPC action methods (excluding subscribed/unsubscribed/stream_*), and the source file path. **`get_job_pattern` now renders an "Action Cable Channels" section with all of these fields**, so AI agents calling the tool actually see the data instead of just the channel name. Also added `eager_load_channels!` to `JobIntrospector` so the channel set is populated in development mode (where `config.eager_load = false` and `ActionCable::Channel::Base.descendants` is otherwise empty until a client subscribes).
83
+
84
+ ### Changed — CI matrix expanded to cover Ruby 4.0 + Rails 8.1
85
+
86
+ - Added Ruby `4.0` and Rails `8.1` to the GitHub Actions test matrix. Net jobs: 12 (was 8). Excludes the unsupported combinations: Ruby 3.2 × Rails 8.x (Rails 8 needs 3.3+) and Ruby 4.0 × Rails 7.x (Rails 7 has no Ruby 4 support). Verified locally that the full spec suite passes on Ruby 4.0.2 + Rails 8.1.3 (the environment in #69) — 1925 examples, 0 failures, rubocop clean across all 282 source files.
87
+
88
+ ### Fixed — Standalone Install Path Crashed Inside Bundler-Backed Rails Apps
89
+
90
+ - **`rails-ai-context` installed via `gem install` (standalone path) crashed on every tool call** when run inside a Rails app that has its own `Gemfile`. Root cause: `boot_rails!` in `exe/rails-ai-context` calls `require config/environment.rb` which runs `Bundler.setup`, which strips `Gem.loaded_specs` to only the app's Gemfile-resolved gems. The MCP SDK reads `Gem.loaded_specs["json-schema"].full_gem_path` at tool-call time (`mcp/tool/schema.rb:45`) — but `json-schema` is a transitive dep of `mcp`, not in the app's Gemfile, so the lookup nils and crashes with `NoMethodError: undefined method 'full_gem_path' for nil`.
91
+ - **Fix:** added `restore_standalone_gem_specs` to `exe/rails-ai-context` which re-registers `mcp`, `json-schema`, and a couple of their transitive deps in `Gem.loaded_specs` after `Bundler.setup` runs. No-op in in-Gemfile mode (the specs are already registered). This was a pre-existing bug that was discovered during v5.8.0 pre-release E2E verification — affected v5.4.0 onward.
92
+
93
+ ### Fixed — MCP Tool Responses Rejected by Strict Clients (#69)
94
+
95
+ - **Removed default `output_schema` from all 38 tools.** Since v5.4.0, `BaseTool.inherited` automatically assigned a `DEFAULT_OUTPUT_SCHEMA` to every tool. The schema described the response wire envelope (`{content: [...]}`) rather than app-level structured data, and tools never returned matching `structured_content`. Per MCP spec, when a tool declares `outputSchema`, it MUST return `structuredContent` matching it. Strict MCP clients (e.g. Copilot CLI) reject responses that don't, with `MCP error -32600: Tool ... has an output schema but did not return structured content`. Lenient clients (Claude Code, Cursor) silently ignored the missing field, which is why the bug went unnoticed since v5.4.0.
96
+ - **Why this happened.** The MCP Ruby SDK does not enforce `output_schema` server-side (no `validate_result` call in `MCP::Server`), so the test suite passed end-to-end. Validation happens client-side, and only strict clients caught it. Reported by @pardeyke.
97
+ - **What changed.** Deleted `DEFAULT_OUTPUT_SCHEMA` constant and the `inherited` hook line that set it (`lib/rails_ai_context/tools/base_tool.rb`). Tools now ship with no `outputSchema` by default — matching what they actually return (text-only). Individual tools can still declare their own `output_schema` via the MCP::Tool DSL, provided they also return matching `structured_content`.
98
+ - **Regression spec added.** `spec/lib/rails_ai_context/tools_spec.rb` now asserts (a) no tool advertises a default `outputSchema`, and (b) any tool that *does* declare one must also have `structured_content:` in its source — preventing the v5.4.0 misuse from sneaking back in.
99
+ - **Future enhancement.** Per-tool structured output (returning parseable JSON alongside the Markdown text via `structured_content:`) is a future feature for tools where it adds value (`get_schema`, `get_routes`, etc.). Out of scope for this patch.
100
+
101
+ ### Added — Framework Association Noise Filter
102
+
103
+ - **`excluded_association_names` config option** — filters framework-generated associations (ActiveStorage, ActionText, ActionMailbox, Noticed) from model introspection output. 7 association names excluded by default. Configurable via initializer (`config.excluded_association_names += %w[...]`) or YAML. Closes #57.
104
+
8
105
  ## [5.7.1] — 2026-04-09
9
106
 
10
107
  ### Changed — SLOP Cleanup
data/README.md CHANGED
@@ -21,7 +21,7 @@
21
21
  <br>
22
22
  [![Ruby](https://img.shields.io/badge/Ruby-3.2%20%7C%203.3%20%7C%203.4-CC342D)](https://github.com/crisnahine/rails-ai-context)
23
23
  [![Rails](https://img.shields.io/badge/Rails-7.1%20%7C%207.2%20%7C%208.0-CC0000)](https://github.com/crisnahine/rails-ai-context)
24
- [![Tests](https://img.shields.io/badge/Tests-1889%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
24
+ [![Tests](https://img.shields.io/badge/Tests-1989%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
25
25
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
26
26
 
27
27
  </div>
@@ -543,7 +543,7 @@ end
543
543
  ## About
544
544
 
545
545
  Built by a Rails developer with 10+ years of production experience.<br>
546
- 1889 tests. 38 tools. 5 resource templates. 31 introspectors. Standalone or in-Gemfile.<br>
546
+ 1989 tests. 38 tools. 5 resource templates. 31 introspectors. Standalone or in-Gemfile.<br>
547
547
  MIT licensed. [Contributions welcome.](CONTRIBUTING.md)
548
548
 
549
549
  <br>
@@ -94,6 +94,7 @@ preset: full
94
94
  | `excluded_filters` | Array | 3 framework filters | Controller filters to skip |
95
95
  | `excluded_middleware` | Array | 24 framework middleware | Middleware to skip in listing |
96
96
  | `excluded_paths` | Array | `["node_modules", "tmp", "log", "vendor", ".git", "doc", "docs"]` | Paths excluded from search |
97
+ | `excluded_association_names` | Array | 7 framework associations | Association names to hide from model output |
97
98
  | `excluded_concerns` | Array of Regex | Framework concerns | Concerns to skip (supports regex) |
98
99
 
99
100
  ### File Size Limits
data/docs/FAQ.md CHANGED
@@ -81,7 +81,7 @@ config.skip_tools = %w[rails_security_scan rails_query]
81
81
 
82
82
  ### What's the `detail` parameter?
83
83
 
84
- Most tools accept `detail`: `summary` (compact), `standard` (default), `full` (everything). Start with summary and drill down as needed. This keeps AI context windows lean.
84
+ Individual lookup tools accept `detail`: `summary` (compact), `standard` (default), `full` (everything). Start with summary and drill down as needed. This keeps AI context windows lean. Composite tools (`rails_get_context`, `rails_analyze_feature`) do not accept `detail` — they always return their full bundled output.
85
85
 
86
86
  ### What are `[VERIFIED]` and `[INFERRED]` tags?
87
87
 
data/docs/GUIDE.md CHANGED
@@ -1243,6 +1243,9 @@ if defined?(RailsAiContext)
1243
1243
  # Route prefixes hidden with app_only (e.g. admin frameworks)
1244
1244
  # config.excluded_route_prefixes += %w[admin/]
1245
1245
 
1246
+ # Framework association names hidden from model output (ActiveStorage, ActionText, etc.)
1247
+ # config.excluded_association_names += %w[my_custom_framework_assoc]
1248
+
1246
1249
  # Regex patterns for concerns to hide from model output
1247
1250
  # config.excluded_concerns += [/MyInternal::/]
1248
1251
 
@@ -1342,6 +1345,7 @@ end
1342
1345
  | `max_validate_files` | Integer | `50` | Max files per validate call |
1343
1346
  | `excluded_controllers` | Array | `DeviseController`, etc. | Controller classes hidden from listings |
1344
1347
  | `excluded_route_prefixes` | Array | `action_mailbox/`, `active_storage/`, etc. | Route controller prefixes hidden with `app_only` |
1348
+ | `excluded_association_names` | Array | 7 framework associations | Framework association names hidden from model output |
1345
1349
  | `excluded_concerns` | Array | framework regex patterns | Regex patterns for concerns to hide from model output |
1346
1350
  | `excluded_filters` | Array | `verify_authenticity_token`, etc. | Framework filter names hidden from controller output |
1347
1351
  | `excluded_middleware` | Array | standard Rails middleware | Default middleware hidden from config output |
data/docs/STANDALONE.md CHANGED
@@ -77,6 +77,9 @@ allow_query_in_production: false
77
77
  # Filtering
78
78
  excluded_models:
79
79
  - ApplicationRecord
80
+ excluded_association_names:
81
+ - active_storage_attachments
82
+ - active_storage_blobs
80
83
  excluded_paths:
81
84
  - node_modules
82
85
  - tmp
data/docs/TOOLS.md CHANGED
@@ -43,7 +43,7 @@ Tool name resolution is flexible — all of these work:
43
43
  | `get_schema` | `rails_get_schema` |
44
44
  | `rails_get_schema` | `rails_get_schema` |
45
45
 
46
- Most tools accept a **`detail`** parameter: `summary` (compact), `standard` (default), or `full` (everything). Start with summary, drill down as needed.
46
+ Individual lookup tools accept a **`detail`** parameter: `summary` (compact), `standard` (default), or `full` (everything). Start with summary, drill down as needed. Composite tools (`rails_get_context`, `rails_analyze_feature`) do not accept `detail`.
47
47
 
48
48
  <p align="right"><a href="#table-of-contents">↑ back to top</a></p>
49
49
 
@@ -144,7 +144,7 @@
144
144
  <div class="stat-label">Introspectors</div>
145
145
  </div>
146
146
  <div class="stat">
147
- <div class="stat-number">1889</div>
147
+ <div class="stat-number">1989</div>
148
148
  <div class="stat-label">Tests</div>
149
149
  </div>
150
150
  <div class="stat">
data/exe/rails-ai-context CHANGED
@@ -414,6 +414,12 @@ class RailsAiContextCLI < Thor
414
414
  (result[:skipped] || []).each { |f| $stderr.puts " Skipped: #{f} (unchanged)" }
415
415
  end
416
416
 
417
+ # Gems that the MCP SDK reads from `Gem.loaded_specs` at tool-call time
418
+ # (specifically `Gem.loaded_specs["json-schema"].full_gem_path` in
419
+ # mcp/tool/schema.rb). Bundler.setup strips them in standalone mode so we
420
+ # capture references BEFORE Rails boot runs and re-register them afterwards.
421
+ STANDALONE_REQUIRED_GEMS = %w[mcp json-schema addressable public_suffix].freeze
422
+
417
423
  def boot_rails!
418
424
  config_path = File.join(Dir.pwd, "config", "environment.rb")
419
425
  unless File.exist?(config_path)
@@ -426,22 +432,54 @@ class RailsAiContextCLI < Thor
426
432
  # Bundler.setup (in config/boot.rb) strips $LOAD_PATH to Gemfile-only gems.
427
433
  # In standalone mode (gem not in Gemfile), this removes our gem and mcp paths.
428
434
  # We restore them after boot so lazy requires (mcp, etc.) still resolve.
429
- #
430
- # We do NOT require mcp or rails_ai_context before boot — that would activate
431
- # gem versions that conflict with Bundler's resolution (e.g., bigdecimal).
432
- # Instead, paths are restored and gems load lazily using Bundler-resolved deps.
433
435
  pre_boot_paths = $LOAD_PATH.dup
434
436
 
437
+ # Capture spec references BEFORE Bundler.setup runs. After Bundler.setup,
438
+ # Gem::Specification.find_by_name is filtered to only Gemfile-resolved gems
439
+ # and our transitive deps (mcp, json-schema) become invisible — find_by_name
440
+ # raises Gem::MissingSpecError. Capturing first means we hold real spec
441
+ # objects we can splice back in after boot. No-op in in-Gemfile mode (the
442
+ # captured specs already exist in Gem.loaded_specs and our `||=` skips them).
443
+ pre_boot_specs = capture_standalone_specs
444
+
435
445
  require config_path
436
446
 
437
- # Restore paths that Bundler.setup stripped (standalone mode).
438
- # For Gemfile users, this is a no-op — no paths were removed.
447
+ # Restore paths and specs that Bundler.setup stripped (standalone mode).
448
+ # For Gemfile users, both restorations are no-ops.
439
449
  (pre_boot_paths - $LOAD_PATH).each { |p| $LOAD_PATH << p }
450
+ pre_boot_specs.each { |name, spec| Gem.loaded_specs[name] ||= spec }
451
+
452
+ # Loud warning if any required spec couldn't be restored — silent failure
453
+ # here means tools will crash later with `undefined method 'full_gem_path'
454
+ # for nil` from inside the MCP SDK.
455
+ missing = STANDALONE_REQUIRED_GEMS.reject { |g| Gem.loaded_specs.key?(g) }
456
+ if missing.any? && !ENV["BUNDLE_BIN_PATH"]
457
+ $stderr.puts "[rails-ai-context] WARNING: standalone CLI could not restore gemspec(s): #{missing.join(', ')}."
458
+ $stderr.puts "[rails-ai-context] Tool calls may crash with `undefined method 'full_gem_path' for nil`."
459
+ $stderr.puts "[rails-ai-context] Try `gem install rails-ai-context` to ensure all transitive deps are installed."
460
+ end
440
461
 
441
462
  require "rails_ai_context"
442
463
 
443
464
  RailsAiContext::Configuration.auto_load!
444
465
  end
466
+
467
+ # Look up each required gem via Gem::Specification.find_by_name BEFORE
468
+ # Bundler.setup runs and filters the spec registry. Returns a hash of
469
+ # {gem_name => Gem::Specification}. In in-Gemfile mode the lookups still
470
+ # succeed but the result is unused (Bundler keeps these specs registered).
471
+ def capture_standalone_specs
472
+ return {} unless defined?(Gem) && Gem.respond_to?(:loaded_specs)
473
+
474
+ STANDALONE_REQUIRED_GEMS.each_with_object({}) do |gem_name, acc|
475
+ spec = Gem::Specification.find_by_name(gem_name)
476
+ acc[gem_name] = spec if spec
477
+ rescue Gem::MissingSpecError, Gem::LoadError
478
+ # The gem is not installed at all (not just Bundler-filtered). Skip
479
+ # silently — the downstream `require` produces a clearer error.
480
+ next
481
+ end
482
+ end
445
483
  end
446
484
 
447
485
  RailsAiContextCLI.start(ARGV)
@@ -203,6 +203,10 @@ module RailsAiContext
203
203
  # Models to exclude from introspection
204
204
  # config.excluded_models += %w[AdminUser InternalThing]
205
205
 
206
+ # Framework association names hidden from model output
207
+ # (ActiveStorage, ActionText, ActionMailbox, Noticed associations are excluded by default)
208
+ # config.excluded_association_names += %w[my_custom_framework_assoc]
209
+
206
210
  # Controllers to exclude from listings
207
211
  # config.excluded_controllers += %w[Admin::BaseController]
208
212
 
@@ -17,13 +17,14 @@ module RailsAiContext
17
17
  server_name cache_ttl max_tool_response_chars
18
18
  live_reload live_reload_debounce auto_mount http_path http_bind http_port
19
19
  output_dir skip_tools excluded_models excluded_controllers
20
- excluded_route_prefixes excluded_filters excluded_middleware excluded_paths
20
+ excluded_route_prefixes excluded_filters excluded_middleware excluded_association_names excluded_paths
21
21
  sensitive_patterns search_extensions concern_paths frontend_paths
22
22
  max_file_size max_test_file_size max_schema_file_size max_view_total_size
23
23
  max_view_file_size max_search_results max_validate_files
24
24
  query_timeout query_row_limit query_redacted_columns allow_query_in_production
25
25
  log_lines introspectors
26
26
  hydration_enabled hydration_max_hints
27
+ instrumentation_include_arguments
27
28
  ].freeze
28
29
 
29
30
  # Load configuration from a YAML file, applying values to the current config instance.
@@ -131,6 +132,19 @@ module RailsAiContext
131
132
  # Debounce interval in seconds for live reload file watching
132
133
  attr_accessor :live_reload_debounce
133
134
 
135
+ # Whether to include raw tool arguments in ActiveSupport::Notifications
136
+ # instrumentation events. Default: false (v5.8.1+). When false, only
137
+ # metadata (method, tool_name, duration, error) is forwarded to
138
+ # subscribers. When true, `tool_arguments` and `arguments` are forwarded
139
+ # verbatim — including raw SQL from `rails_query`, env var names from
140
+ # `rails_get_env`, and log search patterns from `rails_read_logs`.
141
+ #
142
+ # Setting this to true means the operator takes on the redaction
143
+ # obligation for any downstream observability pipeline (Datadog, Scout,
144
+ # custom loggers) that receives these events. See CHANGELOG.md for v5.8.1
145
+ # for the security review that changed the default.
146
+ attr_accessor :instrumentation_include_arguments
147
+
134
148
  # Whether to generate root-level context files (CLAUDE.md, AGENTS.md, etc.)
135
149
  # When false, only generates split rule files (.claude/rules/, .cursor/rules/, etc.)
136
150
  attr_accessor :generate_root_files
@@ -189,12 +203,20 @@ module RailsAiContext
189
203
  /\A(Devise::Models|Devise::Orm|Bullet::|Turbo::|GlobalID::|Rolify::)/
190
204
  ].freeze
191
205
 
206
+ DEFAULT_EXCLUDED_ASSOCIATION_NAMES = %w[
207
+ active_storage_attachments active_storage_blobs
208
+ rich_text_body rich_text_content
209
+ action_mailbox_inbound_emails
210
+ noticed_events noticed_notifications
211
+ ].freeze
212
+
192
213
  # Filtering — customize what's hidden from AI output
193
214
  attr_accessor :excluded_controllers # Controller classes hidden from listings (e.g. DeviseController)
194
215
  attr_accessor :excluded_route_prefixes # Route controller prefixes hidden with app_only (e.g. action_mailbox/)
195
216
  attr_accessor :excluded_concerns # Regex patterns for concerns to hide (e.g. /Devise::Models/)
196
217
  attr_accessor :excluded_filters # Framework filter names hidden from controller output
197
218
  attr_accessor :excluded_middleware # Default middleware hidden from config output
219
+ attr_accessor :excluded_association_names # Framework association names hidden from model output
198
220
 
199
221
  # Search and file discovery
200
222
  attr_accessor :search_extensions # File extensions for Ruby fallback search (default: rb,js,erb,yml,yaml,json)
@@ -221,8 +243,15 @@ module RailsAiContext
221
243
  @introspectors = PRESETS[:full].dup
222
244
  @excluded_paths = %w[node_modules tmp log vendor .git doc docs]
223
245
  @sensitive_patterns = %w[
224
- .env .env.* config/master.key config/credentials.yml.enc
225
- config/credentials/*.yml.enc *.pem *.key
246
+ .env .env.*
247
+ config/master.key
248
+ config/credentials.yml.enc config/credentials/*.yml.enc
249
+ config/database.yml config/secrets.yml
250
+ config/cable.yml config/storage.yml
251
+ config/mongoid.yml config/redis.yml
252
+ *.pem *.key *.p12 *.pfx *.jks *.keystore
253
+ **/id_rsa **/id_ed25519 **/id_ecdsa **/id_dsa
254
+ .ssh/* .aws/credentials .aws/config .netrc .pgpass .my.cnf
226
255
  ]
227
256
  @auto_mount = false
228
257
  @http_path = "/mcp"
@@ -241,6 +270,7 @@ module RailsAiContext
241
270
  @max_tool_response_chars = 200_000
242
271
  @live_reload = :auto
243
272
  @live_reload_debounce = 1.5
273
+ @instrumentation_include_arguments = false
244
274
  @generate_root_files = true
245
275
  @anti_hallucination_rules = true
246
276
  @max_file_size = 5_000_000
@@ -255,6 +285,7 @@ module RailsAiContext
255
285
  @excluded_concerns = DEFAULT_EXCLUDED_CONCERNS.dup
256
286
  @excluded_filters = DEFAULT_EXCLUDED_FILTERS.dup
257
287
  @excluded_middleware = DEFAULT_EXCLUDED_MIDDLEWARE.dup
288
+ @excluded_association_names = DEFAULT_EXCLUDED_ASSOCIATION_NAMES.dup
258
289
  @custom_tools = []
259
290
  @skip_tools = []
260
291
  @ai_tools = nil
@@ -39,13 +39,13 @@ module RailsAiContext
39
39
  # Include the gem's own version so cache invalidates during gem development
40
40
  digest.update(RailsAiContext::VERSION)
41
41
 
42
- # Include gem lib directory mtime when using a local/path gem (development mode)
43
- gem_lib = File.expand_path("../../..", __FILE__)
44
- if gem_lib.start_with?(root) || (defined?(Bundler) && local_gem_path?)
45
- Dir.glob(File.join(gem_lib, "**/*.rb")).sort.each do |path|
46
- digest.update(File.mtime(path).to_f.to_s)
47
- end
48
- end
42
+ # Include gem lib directory fingerprint when using a local/path gem.
43
+ # MEMOIZED the gem lib contents don't change within a single process
44
+ # lifetime unless a developer is actively editing the gem source (rare
45
+ # audience, they should restart the server to see changes). Previously
46
+ # this walked 123 gem files on every tool call, adding ~12ms to the
47
+ # cached_context hot path for path:-installed users.
48
+ digest.update(gem_lib_fingerprint(root))
49
49
 
50
50
  WATCHED_FILES.each do |file|
51
51
  path = File.join(root, file)
@@ -68,8 +68,35 @@ module RailsAiContext
68
68
  digest.hexdigest
69
69
  end
70
70
 
71
+ # Clear the memoized gem-lib fingerprint. Called by BaseTool.reset_cache!
72
+ # and LiveReload so active gem development gets a fresh scan on next call
73
+ # without requiring a process restart.
74
+ def reset_gem_lib_fingerprint!
75
+ @gem_lib_fingerprint = nil
76
+ end
77
+
71
78
  private
72
79
 
80
+ # Memoized gem-lib fingerprint. Computed ONCE per process lifetime
81
+ # (or per reset_gem_lib_fingerprint! call) instead of on every
82
+ # tool invocation.
83
+ def gem_lib_fingerprint(root)
84
+ @gem_lib_fingerprint ||= compute_gem_lib_fingerprint(root)
85
+ end
86
+
87
+ def compute_gem_lib_fingerprint(root)
88
+ gem_lib = File.expand_path("../../..", __FILE__)
89
+ return "" unless gem_lib.start_with?(root) || (defined?(Bundler) && local_gem_path?)
90
+
91
+ sub = Digest::SHA256.new
92
+ Dir.glob(File.join(gem_lib, "**/*.rb")).sort.each do |path|
93
+ sub.update(File.mtime(path).to_f.to_s)
94
+ rescue Errno::ENOENT
95
+ # File deleted between glob and mtime read — skip
96
+ end
97
+ sub.hexdigest
98
+ end
99
+
73
100
  # Detect if this gem is loaded via a local path (path: in Gemfile)
74
101
  def local_gem_path?
75
102
  spec = Bundler.rubygems.find_name("rails-ai-context").first
@@ -6,17 +6,60 @@ module RailsAiContext
6
6
  module Instrumentation
7
7
  EVENT_PREFIX = "rails_ai_context"
8
8
 
9
+ # Metadata-only fields forwarded to ActiveSupport::Notifications. We
10
+ # deliberately exclude `tool_arguments`, `params`, `arguments`, and
11
+ # `request` because the MCP SDK includes raw tool inputs in those keys —
12
+ # e.g. `rails_query(sql: "SELECT password_digest...")`, `rails_get_env(name: "SECRET_KEY_BASE")`,
13
+ # `rails_read_logs(search: "api_key=xyz")`. Forwarding them unredacted
14
+ # would leak the request-side data that each tool's response-side
15
+ # redaction was specifically designed to protect. Fixed in v5.8.1.
16
+ #
17
+ # Users who legitimately need tool arguments in observability can set
18
+ # config.instrumentation_include_arguments = true in an initializer
19
+ # (see CONFIGURATION.md for the redaction obligation that comes with it).
20
+ SAFE_KEYS = %i[method tool_name duration error resource_uri prompt_name].freeze
21
+
9
22
  # Returns a lambda for MCP::Configuration#instrumentation_callback.
10
23
  # Instruments each MCP method call as an ActiveSupport::Notifications event.
11
24
  def self.callback
12
25
  ->(data) {
13
26
  return unless defined?(ActiveSupport::Notifications)
14
27
 
15
- method = data[:method] || "unknown"
16
- event_name = "#{EVENT_PREFIX}.#{method.tr("/", ".")}"
28
+ begin
29
+ method = data[:method] || "unknown"
30
+ event_name = "#{EVENT_PREFIX}.#{method.to_s.tr("/", ".")}"
17
31
 
18
- ActiveSupport::Notifications.instrument(event_name, data)
32
+ # build_payload reads configuration — wrap it in the rescue so a
33
+ # broken/nil configuration doesn't propagate out of the lambda and
34
+ # crash the MCP SDK's ensure block. v5.8.1-r3 hardening.
35
+ payload = build_payload(data)
36
+ ActiveSupport::Notifications.instrument(event_name, payload)
37
+ rescue => e
38
+ # The MCP SDK's instrument_call invokes this callback from an `ensure`
39
+ # block, which means any exception raised here would overwrite the
40
+ # tool's actual return value with the subscriber's error — effectively
41
+ # crashing every tool call whenever a single subscriber is broken.
42
+ # Swallow the error and log to stderr instead. Fixed in v5.8.1.
43
+ $stderr.puts "[rails-ai-context] instrumentation subscriber failed: #{e.message}" if ENV["DEBUG"]
44
+ end
19
45
  }
20
46
  end
47
+
48
+ # Build a safe payload from the raw MCP SDK data hash. Strips tool
49
+ # arguments unless the user has explicitly opted in.
50
+ def self.build_payload(data)
51
+ payload = SAFE_KEYS.each_with_object({}) do |key, acc|
52
+ acc[key] = data[key] if data.key?(key)
53
+ end
54
+
55
+ if RailsAiContext.configuration.instrumentation_include_arguments
56
+ # User opted in: include arguments verbatim. They take on the
57
+ # redaction obligation for anything downstream consumers log.
58
+ payload[:tool_arguments] = data[:tool_arguments] if data.key?(:tool_arguments)
59
+ payload[:arguments] = data[:arguments] if data.key?(:arguments)
60
+ end
61
+
62
+ payload
63
+ end
21
64
  end
22
65
  end
@@ -36,10 +36,9 @@ module RailsAiContext
36
36
  devise_models = scan_models_for(/devise\s+(.+)$/)
37
37
  auth[:devise] = devise_models if devise_models.any?
38
38
 
39
- # Rails 8 built-in auth
40
- if file_exists?("app/models/session.rb") && file_exists?("app/models/current.rb")
41
- auth[:rails_auth] = true
42
- end
39
+ # Rails 8 built-in auth (`bin/rails generate authentication`)
40
+ rails_auth = detect_rails_auth
41
+ auth[:rails_auth] = rails_auth if rails_auth
43
42
 
44
43
  # has_secure_password
45
44
  secure_pw = scan_models_for(/has_secure_password/)
@@ -56,6 +55,65 @@ module RailsAiContext
56
55
  auth
57
56
  end
58
57
 
58
+ # Rails 8's `bin/rails generate authentication` produces a Session model,
59
+ # a Current attributes model, an Authentication concern in app/controllers/concerns,
60
+ # a SessionsController, and a PasswordsController. AI agents need to know:
61
+ #
62
+ # 1. that this app uses the built-in pattern (not Devise / not custom)
63
+ # 2. which controllers opt out via `allow_unauthenticated_access`
64
+ # 3. where the Authentication concern lives so they can find before_actions
65
+ def detect_rails_auth
66
+ return nil unless file_exists?("app/models/session.rb") && file_exists?("app/models/current.rb")
67
+
68
+ result = { detected: true }
69
+
70
+ result[:authentication_concern] = "app/controllers/concerns/authentication.rb" if file_exists?("app/controllers/concerns/authentication.rb")
71
+ result[:sessions_controller] = "app/controllers/sessions_controller.rb" if file_exists?("app/controllers/sessions_controller.rb")
72
+ result[:passwords_controller] = "app/controllers/passwords_controller.rb" if file_exists?("app/controllers/passwords_controller.rb")
73
+
74
+ unauth = scan_allow_unauthenticated_access
75
+ result[:allow_unauthenticated_access] = unauth if unauth.any?
76
+
77
+ result
78
+ rescue => e
79
+ $stderr.puts "[rails-ai-context] detect_rails_auth failed: #{e.message}" if ENV["DEBUG"]
80
+ nil
81
+ end
82
+
83
+ def scan_allow_unauthenticated_access
84
+ controllers_dir = File.join(root, "app/controllers")
85
+ return [] unless Dir.exist?(controllers_dir)
86
+
87
+ Dir.glob(File.join(controllers_dir, "**/*.rb")).flat_map do |path|
88
+ content = RailsAiContext::SafeFile.read(path) or next []
89
+ next [] unless content.match?(/\ballow_unauthenticated_access\b/)
90
+
91
+ relative = path.sub("#{root}/", "")
92
+ # Capture every `only:` / `except:` declaration in the file. A single
93
+ # controller can have multiple (e.g. `only:` + `except:` mixed via concerns).
94
+ scoped = content.scan(/allow_unauthenticated_access\s+(only|except):\s*(\[?[^\n]+)/)
95
+
96
+ if scoped.empty?
97
+ [ { file: relative, scope: "all actions" } ]
98
+ else
99
+ scoped.map do |kw, value|
100
+ { file: relative, scope: "#{kw}: #{strip_trailing_comment(value).strip}" }
101
+ end
102
+ end
103
+ end.compact.sort_by { |h| h[:file] }
104
+ rescue => e
105
+ $stderr.puts "[rails-ai-context] scan_allow_unauthenticated_access failed: #{e.message}" if ENV["DEBUG"]
106
+ []
107
+ end
108
+
109
+ # Strip a trailing `# comment` from a captured scope value while preserving
110
+ # `#` characters that appear inside string literals or array element syntax.
111
+ # Conservative: only strips when the `#` is preceded by whitespace, which
112
+ # matches the common style (`only: %i[index] # comment`).
113
+ def strip_trailing_comment(value)
114
+ value.sub(/\s+#.*\z/, "")
115
+ end
116
+
59
117
  def detect_authorization
60
118
  authz = {}
61
119