rails-ai-context 5.16.2 → 5.18.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -10
  3. data/AGENTS.md +15 -0
  4. data/CHANGELOG.md +103 -0
  5. data/CONTRIBUTING.md +7 -5
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +8 -1
  7. data/docs/ARCHITECTURE.md +5 -5
  8. data/docs/CONFIGURATION.md +15 -4
  9. data/docs/INTROSPECTORS.md +49 -3
  10. data/docs/SECURITY.md +27 -16
  11. data/docs/agents/domain.md +38 -0
  12. data/docs/agents/issue-tracker.md +45 -0
  13. data/docs/agents/triage-labels.md +15 -0
  14. data/lib/rails_ai_context/configuration.rb +3 -1
  15. data/lib/rails_ai_context/detail_level.rb +40 -0
  16. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -0
  17. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +2 -0
  18. data/lib/rails_ai_context/introspectors/api_introspector.rb +1 -11
  19. data/lib/rails_ai_context/introspectors/auth_introspector.rb +31 -15
  20. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +18 -9
  21. data/lib/rails_ai_context/introspectors/component_introspector.rb +37 -61
  22. data/lib/rails_ai_context/introspectors/controller_introspector.rb +50 -20
  23. data/lib/rails_ai_context/introspectors/convention_introspector.rb +14 -11
  24. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  25. data/lib/rails_ai_context/introspectors/gem_introspector.rb +0 -2
  26. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +9 -4
  27. data/lib/rails_ai_context/introspectors/job_introspector.rb +21 -14
  28. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +42 -4
  29. data/lib/rails_ai_context/introspectors/listeners/chained_call_listener.rb +19 -1
  30. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +32 -0
  31. data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +118 -0
  32. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +97 -0
  33. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +8 -5
  34. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +11 -0
  35. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -0
  36. data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -5
  37. data/lib/rails_ai_context/introspectors/performance_introspector.rb +29 -60
  38. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +3 -16
  39. data/lib/rails_ai_context/introspectors/schema_introspector.rb +85 -182
  40. data/lib/rails_ai_context/introspectors/schema_reader.rb +166 -0
  41. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +5 -10
  42. data/lib/rails_ai_context/introspectors/source_introspector.rb +4 -4
  43. data/lib/rails_ai_context/introspectors/test_introspector.rb +10 -7
  44. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +11 -6
  45. data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -0
  46. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +5 -0
  47. data/lib/rails_ai_context/mcp_config_generator.rb +5 -12
  48. data/lib/rails_ai_context/safe_file.rb +14 -0
  49. data/lib/rails_ai_context/serializers/claude_serializer.rb +7 -9
  50. data/lib/rails_ai_context/serializers/context_file_serializer.rb +1 -8
  51. data/lib/rails_ai_context/serializers/context_mode_dispatch.rb +25 -0
  52. data/lib/rails_ai_context/serializers/copilot_serializer.rb +4 -13
  53. data/lib/rails_ai_context/serializers/markdown_serializer.rb +1 -1
  54. data/lib/rails_ai_context/serializers/opencode_serializer.rb +4 -13
  55. data/lib/rails_ai_context/serializers/section_marker_writer.rb +1 -6
  56. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +1 -1
  57. data/lib/rails_ai_context/tools/analyze_feature.rb +1 -1
  58. data/lib/rails_ai_context/tools/diagnose.rb +2 -2
  59. data/lib/rails_ai_context/tools/generate_test.rb +1 -1
  60. data/lib/rails_ai_context/tools/get_callbacks.rb +2 -2
  61. data/lib/rails_ai_context/tools/get_concern.rb +2 -2
  62. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -1
  63. data/lib/rails_ai_context/tools/get_helper_methods.rb +1 -1
  64. data/lib/rails_ai_context/tools/get_schema.rb +1 -1
  65. data/lib/rails_ai_context/tools/get_test_info.rb +1 -1
  66. data/lib/rails_ai_context/tools/get_view.rb +1 -1
  67. data/lib/rails_ai_context/tools/onboard.rb +3 -3
  68. data/lib/rails_ai_context/tools/performance_check.rb +3 -3
  69. data/lib/rails_ai_context/tools/query.rb +10 -7
  70. data/lib/rails_ai_context/tools/review_changes.rb +2 -2
  71. data/lib/rails_ai_context/tools/runtime_info.rb +4 -4
  72. data/lib/rails_ai_context/tools/search_code.rb +2 -2
  73. data/lib/rails_ai_context/tools/validate.rb +2 -864
  74. data/lib/rails_ai_context/tools/validate_semantics.rb +879 -0
  75. data/lib/rails_ai_context/version.rb +1 -1
  76. metadata +12 -2
  77. data/docs/superpowers/plans/2026-05-24-prism-ast-migration.md +0 -1218
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eea6e2b178ca5641f69ba9e78d20faa743e9639c706b2a8f847e60f854618b97
4
- data.tar.gz: d688b1ac2b70859c0f2cdfecfb731bf0dda802743924664967f15680b4e9042f
3
+ metadata.gz: d4a8615479dc45f376da0873dbd18511feda7dafddad0e4c4c14039d26f8a4bf
4
+ data.tar.gz: 9fa06c53d871f05d30c284964777ebd15b26d44e87284e9232591e288ab47d29
5
5
  SHA512:
6
- metadata.gz: bb7f069772ba9a58014b905eb68629cf8024b767c7cd01f409fbcd4418f252e04b7d0618c39160bcc7f2364e4535240372da158d267284f721fb365409216699
7
- data.tar.gz: b22aac0130e6ee73829982f5dce526036c5e944ed2104f93664ec89868df7a1db26ea12412f5d3c6d73c7daa9a4421e2e61bf7054f67814fde12b9da70c3a206
6
+ metadata.gz: 38cc97cb5ec47c4ecd680ebb588169372d4d5a41c53dd0c59fde9260dc2bffad4f9830ffea5bc84cbef0079b167d522bccf77903104e5f6e5a03bd93aa85445d
7
+ data.tar.gz: 9987140ad897944e8def9318c6eedad861228f345465937062dba5e6ea36a2c47246d5c0697acce041afe7966b866d40bd289e67eeb1d582a6b220cc5c6dc147
data/.rubocop.yml CHANGED
@@ -11,13 +11,6 @@ AllCops:
11
11
  Style/Documentation:
12
12
  Enabled: false
13
13
 
14
- Metrics/MethodLength:
15
- Max: 25
16
-
17
- Metrics/ClassLength:
18
- Max: 200
19
-
20
- Metrics/BlockLength:
21
- Exclude:
22
- - "spec/**/*"
23
- - "*.gemspec"
14
+ # rubocop-rails-omakase disables the whole Metrics department. Setting Max on a
15
+ # disabled cop does not re-enable it, so size limits here would enforce nothing.
16
+ # Turning it on would need ~40 existing files brought under the limit first.
data/AGENTS.md ADDED
@@ -0,0 +1,15 @@
1
+ # rails-ai-context
2
+
3
+ ## Agent skills
4
+
5
+ ### Issue tracker
6
+
7
+ Issues live as GitHub issues on `crisnahine/rails-ai-context`, driven with the `gh` CLI. See `docs/agents/issue-tracker.md`.
8
+
9
+ ### Triage labels
10
+
11
+ The five canonical roles, each label string equal to its name (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`.
12
+
13
+ ### Domain docs
14
+
15
+ Single-context: `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`.
data/CHANGELOG.md CHANGED
@@ -5,6 +5,109 @@ 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.18.0] - 2026-08-09
9
+
10
+ ### Added
11
+
12
+ - **Three new Prism listeners.** `ConfigAssignmentListener` reads
13
+ `config.key = value` and `config.key.subkey = value` in initializers, matching
14
+ the root anywhere in the chain so `Rails.application.config.assets.paths`
15
+ resolves too. `ComponentStructureListener` reads ViewComponent and Phlex
16
+ structure: `renders_one`/`renders_many`, slot methods, constant tables,
17
+ `case @ivar` variant branching, and `CONST[@ivar]` indexing.
18
+ `ClassDefinitionListener` reads class definitions with their superclass.
19
+ - **`docs/INTROSPECTORS.md` documents the listener catalogue**, how to add a
20
+ listener, and when regex is the right tool instead of the AST.
21
+
22
+ ### Changed
23
+
24
+ - **Auth, component, channel, controller, inflection and initializer reading
25
+ moved from regex to the AST.** Devise and Doorkeeper settings, devise-jwt
26
+ detection, ViewComponent and Phlex structure, Action Cable `identified_by` /
27
+ `stream_from` / `stream_for` / `periodically`, `rate_limit` options, custom
28
+ inflections, CORS origins, RSpec helper `include`s, `DatabaseCleaner.strategy`
29
+ and model class detection are all read structurally now. Formatting that used
30
+ to defeat the patterns (multi-line arguments, adjacent string literals,
31
+ `%i[]` and `%w[]` forms) is read correctly.
32
+ - **A filter's `if:` condition reports the action it names.** A lambda has no
33
+ literal value, so `if: -> { action_name == "create" }` used to surface as
34
+ `[INFERRED]`; it now reads `action_name == "create"`. Same key, same type.
35
+ - **`rate_limit_parsed[:within]` no longer keeps a trailing comma.**
36
+ `within: 1.minute, only: :create` returned `"1.minute,"` and now returns
37
+ `"1.minute"`.
38
+ - **Initializer `setup_calls` sees more.** The old pattern only matched a line
39
+ beginning with `config.`, so `Rails.application.config.x = y` and multi-line
40
+ chains were missed.
41
+ - **Every remaining regex over Ruby source carries a note** saying why regex is
42
+ right there: non-Ruby files, mixed-extension globs, vocabulary matching, or
43
+ scope the listeners cannot see.
44
+
45
+ ### Fixed
46
+
47
+ - **Dead namespace-tracking loop removed from `RakeTaskIntrospector`**, which
48
+ walked every line of every `.rake` file and discarded the result.
49
+
50
+ ## [5.17.0] - 2026-08-09
51
+
52
+ ### Added
53
+
54
+ - **`config.query_allowed_columns`** exempts a column name from the built-in
55
+ sensitive list used by `rails_query`. The rejection message told you to
56
+ subtract from `query_redacted_columns`, which could never work: that list is
57
+ unioned with a frozen suffix list, so an app with its own
58
+ `oauth_applications.secret` had no way to query it.
59
+
60
+ ### Fixed
61
+
62
+ - **Documented security behaviour now matches the code.** The ReDoS timeout on
63
+ user-supplied regexes needs `Regexp.timeout`, which Ruby 3.1 does not have,
64
+ so `SECURITY.md` no longer promises it unconditionally on a version the gem
65
+ still supports. Layer 4 was documented as post-execution redaction; it has
66
+ rejected the query outright since 5.8.1, which is what the doc now says.
67
+ - **`config.introspectors = %i[source]` no longer appears to be supported.**
68
+ `SourceIntrospector` was listed in the introspector table but is
69
+ infrastructure, not a registered introspector, so configuring it raised
70
+ `ConfigurationError`.
71
+ - **Corrected documented defaults**, which had drifted: `sensitive_patterns` is
72
+ 27 patterns not 8, `excluded_middleware` 25 not 24, `excluded_filters` 5 not
73
+ 3, and the listener count is 21. `extra_app_paths` and
74
+ `instrumentation_include_arguments` were undocumented.
75
+ - **Secondary database dumps report their own generated columns.** Parsing
76
+ `db/queue_schema.rb` read generated columns from the primary `db/schema.rb`
77
+ instead of the dump being parsed.
78
+ - **Polymorphic foreign keys no longer always report a missing index.** The
79
+ compound check compared one joined string against two column names, so it
80
+ could never match an existing index.
81
+ - **A column is no longer treated as indexed because a wider column name is.**
82
+ Index matching compared substrings, so an index on `user_id` counted as
83
+ covering `id`.
84
+
85
+ ### Changed
86
+
87
+ - **schema.rb is read through one AST-backed reader.** `SchemaIntrospector`,
88
+ `PerformanceIntrospector` and `ConventionIntrospector` each parsed the dump
89
+ line by line with their own `create_table` regex; they now share
90
+ `SchemaReader`, built on the existing `SchemaDslListener`, as do the static
91
+ tier's table parse and the check-constraint, enum and generated-column
92
+ reads that each walked the dump separately.
93
+ - **The static schema tier reports four things differently.** Where no live
94
+ connection is available and `db/schema.rb` is parsed instead: a `t.references`
95
+ or `t.belongs_to` column is reported by its foreign key name (`author_id`,
96
+ not `author`), which is what the live tier has always reported; a column
97
+ default written as a proc reports its source rather than being omitted; a
98
+ default split across lines is picked up; and an expression index declared at
99
+ the top level is flagged `expression: true`, as an in-table one already was.
100
+ - **`rails_validate`'s Rails-aware rules moved to `ValidateSemantics`.** The
101
+ tool entry point kept syntax validation and orchestration; the 15 lint rules
102
+ now live in their own class. Behaviour is unchanged.
103
+ - **`detail` has a type.** `DetailLevel` defines the three levels, their
104
+ ordering and normalization. An unrecognised value now reads as `standard`
105
+ rather than silently selecting whichever branch happened to be last.
106
+ - **Soft-delete detection prefers the schema.** A `deleted_at` column in the
107
+ dump now drives the `soft_delete` convention, instead of matching the word
108
+ anywhere in model source. Apps dumping `structure.sql` keep the old source
109
+ match, and the `acts_as_paranoid` / `discard` macro check is unchanged.
110
+
8
111
  ## [5.16.2] - 2026-07-17
9
112
 
10
113
  ### Fixed
data/CONTRIBUTING.md CHANGED
@@ -20,7 +20,7 @@ The test suite uses [Combustion](https://github.com/pat/combustion) to boot a mi
20
20
  lib/rails_ai_context/
21
21
  ├── cli/ # CLI tool runner (tool_runner.rb) - executes MCP tools from rake/Thor
22
22
  ├── introspectors/ # 39 introspectors (schema, models, routes, etc.)
23
- ├── tools/ # 38 MCP tools with detail levels and pagination
23
+ ├── tools/ # 39 MCP tools with detail levels and pagination
24
24
  ├── serializers/ # Per-assistant formatters + shared ToolGuideHelper
25
25
  ├── server.rb # MCP server setup (stdio + HTTP)
26
26
  ├── live_reload.rb # MCP live reload (file watcher + cache invalidation)
@@ -51,11 +51,13 @@ Listeners extract specific concerns (associations, validations, etc.) from the A
51
51
  1. Create `lib/rails_ai_context/introspectors/listeners/your_listener.rb` inheriting from `BaseListener`
52
52
  2. Implement `on_call_node_enter(node)` and/or `on_def_node_enter(node)` - only the events your concern needs
53
53
  3. Use `confidence_for(node)` from `BaseListener` to tag results `[VERIFIED]` or `[INFERRED]`
54
- 4. Store results in `@results` (accessed via `#results`)
55
- 5. Register the key/class pair in `SourceIntrospector::LISTENER_MAP`
56
- 6. Write specs in `spec/lib/rails_ai_context/introspectors/listeners/your_listener_spec.rb`
54
+ 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
56
+ 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
+ 7. Write specs in `spec/lib/rails_ai_context/introspectors/listeners/your_listener_spec.rb`
58
+ 8. Add a row to the listener catalogue in `docs/INTROSPECTORS.md`
57
59
 
58
- See existing listeners in `lib/rails_ai_context/introspectors/listeners/` for reference patterns.
60
+ See existing listeners in `lib/rails_ai_context/introspectors/listeners/` for reference patterns, and `docs/INTROSPECTORS.md` for the catalogue and the AST-vs-regex rule.
59
61
 
60
62
  ## Adding a CLI Tool Interface
61
63
 
@@ -29,7 +29,14 @@ module RailsAiContext
29
29
  def handle
30
30
  status_code, rack_headers, body = self.class.mcp_transport.handle_request(request)
31
31
  self.status = status_code
32
- rack_headers.each { |k, v| response.headers[k] = v }
32
+ rack_headers.each do |k, v|
33
+ # mcp >= 1.0 returns Rack 3-style lowercase header keys. Rails 7.0's
34
+ # response header hash is case-sensitive (Rack 2), so a lowercase
35
+ # "content-type" never registers and Rails falls back to text/html on
36
+ # an otherwise valid JSON body. Write it with the canonical case.
37
+ key = k.casecmp("content-type").zero? ? "Content-Type" : k
38
+ response.headers[key] = v
39
+ end
33
40
 
34
41
  if body.respond_to?(:each)
35
42
  # Plain enumerable body (initialize, errors, JSON mode): join to a
data/docs/ARCHITECTURE.md CHANGED
@@ -25,8 +25,8 @@ graph TD
25
25
 
26
26
  subgraph engine["Introspection Engine"]
27
27
  direction LR
28
- I["Introspectors\n31 modules\nPresets\nCached"]
29
- AST["AST Engine\nPrism\n20 listeners\nConfidence tags"]
28
+ I["Introspectors\n39 modules\nPresets\nCached"]
29
+ AST["AST Engine\nPrism\n24 listeners\nConfidence tags"]
30
30
  H["Hydration Layer\nSchema hints\ninjected into\ntool responses"]
31
31
  end
32
32
 
@@ -73,7 +73,7 @@ sequenceDiagram
73
73
  alt cache hit (TTL + fingerprint valid)
74
74
  Cache-->>TR: cached context
75
75
  else cache miss
76
- Cache->>App: introspect (31 modules)
76
+ Cache->>App: introspect (39 modules)
77
77
  App-->>Cache: structured data
78
78
  Cache-->>TR: fresh context
79
79
  end
@@ -115,8 +115,8 @@ The `Introspector` orchestrator runs configured introspectors and merges results
115
115
  **Prism AST parsing** replaced all regex-based Ruby source parsing in v5.2.0.
116
116
 
117
117
  - **AstCache** - Thread-safe parse cache (`Concurrent::Map`), keyed by path + SHA256 + mtime
118
- - **SourceIntrospector** - Single-pass Prism Dispatcher walks the AST once, feeds events to all 7 listeners simultaneously
119
- - **7 Listeners** - Associations, Validations, Scopes, Enums, Callbacks, Macros, Methods
118
+ - **SourceIntrospector** - Single-pass Prism Dispatcher walks the AST once, feeding every registered listener simultaneously
119
+ - **21 Listeners** - Associations, Validations, Scopes, Enums, Callbacks, Macros and Methods are the default map for model analysis; the rest are used through targeted walks over schema dumps, migrations, Gemfiles, rake tasks and initializers
120
120
  - **Confidence** - Every result carries `[VERIFIED]` (static literals) or `[INFERRED]` (dynamic expressions)
121
121
 
122
122
  ### Tool Registry (`lib/rails_ai_context/tools/base_tool.rb`)
@@ -91,8 +91,8 @@ preset: full
91
91
  | `excluded_models` | Array | 8 framework models | Models to skip during introspection |
92
92
  | `excluded_controllers` | Array | 2 framework controllers | Controllers to skip |
93
93
  | `excluded_route_prefixes` | Array | 6 framework prefixes | Route prefixes to skip |
94
- | `excluded_filters` | Array | 3 framework filters | Controller filters to skip |
95
- | `excluded_middleware` | Array | 24 framework middleware | Middleware to skip in listing |
94
+ | `excluded_filters` | Array | 5 framework filters | Controller filters to skip |
95
+ | `excluded_middleware` | Array | 25 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
97
  | `excluded_association_names` | Array | 7 framework associations | Association names to hide from model output |
98
98
  | `excluded_concerns` | Array of Regex | Framework concerns | Concerns to skip (supports regex) |
@@ -116,6 +116,16 @@ preset: full
116
116
  | `search_extensions` | Array | `["rb", "js", "erb", "yml", "yaml", "json", "ts", "tsx", "vue", "svelte", "haml", "slim"]` | File extensions to search |
117
117
  | `concern_paths` | Array | `["app/models/concerns", "app/controllers/concerns"]` | Paths to scan for concerns |
118
118
  | `frontend_paths` | Array | `nil` (auto-detect) | Override frontend file paths |
119
+ | `extra_app_paths` | Array | `[]` | Extra directories under the app root to treat as application code |
120
+
121
+ ### Instrumentation
122
+
123
+ | Option | Type | Default | Description |
124
+ |:-------|:-----|:--------|:------------|
125
+ | `instrumentation_include_arguments` | Boolean | `false` | Include tool arguments in instrumentation events |
126
+
127
+ > [!WARNING]
128
+ > `instrumentation_include_arguments` forwards raw tool arguments to your subscribers, which for `rails_query` means the SQL text and for other tools can mean environment variable names. Leave it off unless your subscriber is as trusted as your logs.
119
129
 
120
130
  ### Database Query Safety
121
131
 
@@ -123,7 +133,8 @@ preset: full
123
133
  |:-------|:-----|:--------|:-----------|:------------|
124
134
  | `query_timeout` | Integer | `5` | - | SQL query timeout in seconds |
125
135
  | `query_row_limit` | Integer | `100` | 1–1000 | Maximum rows returned |
126
- | `query_redacted_columns` | Array | 10+ patterns | - | Column names/suffixes to redact |
136
+ | `query_redacted_columns` | Array | 14 patterns | - | Column names that cause a query to be rejected |
137
+ | `query_allowed_columns` | Array | `[]` | - | Column names exempted from the built-in sensitive list |
127
138
  | `allow_query_in_production` | Boolean | `false` | - | Allow `rails_query` tool in production |
128
139
 
129
140
  ### Logs
@@ -136,7 +147,7 @@ preset: full
136
147
 
137
148
  | Option | Type | Default | Description |
138
149
  |:-------|:-----|:--------|:------------|
139
- | `sensitive_patterns` | Array | 8 patterns | File patterns blocked from search/read (`.env*`, `*.key`, `*.pem`, `credentials.yml.enc`, etc.) |
150
+ | `sensitive_patterns` | Array | 27 patterns | File patterns blocked from search/read (`.env*`, `*.key`, `*.pem`, `config/credentials.yml.enc`, `.ssh/*`, etc.) |
140
151
 
141
152
  ### Extensibility
142
153
 
@@ -94,7 +94,6 @@ end
94
94
 
95
95
  | Introspector | Key | What it extracts |
96
96
  |:-------------|:----|:-----------------|
97
- | SourceIntrospector | `:source` | Prism AST: associations, validations, scopes, enums, callbacks, macros, methods |
98
97
  | MigrationIntrospector | `:migrations` | Migration files, versions, reversibility |
99
98
  | SeedsIntrospector | `:seeds` | Seed file analysis |
100
99
  | DatabaseStatsIntrospector | `:database_stats` | Table sizes, row counts, index stats |
@@ -170,9 +169,11 @@ These introspectors map directly onto [`RAILS_NERVOUS_SYSTEM.md`](../RAILS_NERVO
170
169
 
171
170
  ## AST-based introspection
172
171
 
173
- The **SourceIntrospector** uses Prism AST parsing for model analysis. It runs a single-pass Dispatcher that walks the AST once and feeds events to 7 listeners simultaneously:
172
+ The **SourceIntrospector** uses Prism AST parsing for model analysis. It is infrastructure shared by the introspectors above rather than an introspector you can enable: there is no `:source` key for `config.introspectors`.
174
173
 
175
- ### 7 Prism listeners
174
+ It runs a single-pass Dispatcher that walks the AST once and feeds events to all registered listeners simultaneously. Model analysis uses the seven below by default; the rest are used through targeted walks (schema dumps, migrations, Gemfiles, rake tasks, initializers, components, and so on).
175
+
176
+ ### The 7 default Prism listeners
176
177
 
177
178
  | Listener | What it detects |
178
179
  |:---------|:---------------|
@@ -184,6 +185,51 @@ The **SourceIntrospector** uses Prism AST parsing for model analysis. It runs a
184
185
  | MacrosListener | `encrypts`, `normalizes`, `delegate`, `has_secure_password`, `serialize`, `store`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `generates_token_for`, `attribute` |
185
186
  | MethodsListener | `def`/`def self.`, visibility tracking, parameter extraction, `class << self` |
186
187
 
188
+ ### The targeted-walk listeners
189
+
190
+ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file needs reading. Several take arguments, so one class serves many callers.
191
+
192
+ | Listener | What it detects |
193
+ |:---------|:---------------|
194
+ | GenericMacroListener | Any receiver-less macro you name: `GenericMacroListener.new(:devise, :rate_limit)`. Returns args, values (with a source-slice fallback), options, option values and option nodes |
195
+ | ChainedCallListener | Calls on a receiver: `ChainedCallListener.new(:includes)`, or `receiver: :inflect` to pin the receiver. Reports the receiver name |
196
+ | ConfigAssignmentListener | `config.key = value` and `config.a.b = value` in initializers, plus bare `config.jwt do ... end` section references. Takes a root name (`:config` by default, e.g. `:DatabaseCleaner`) |
197
+ | ClassDefinitionListener | Class definitions with their superclass, namespaces resolved |
198
+ | ComponentStructureListener | ViewComponent and Phlex structure: `renders_one`/`renders_many`, slot methods, hash/array constant tables, `case @ivar` variant branching, `CONST[@ivar]` indexing |
199
+ | MiddlewareConfigListener | `config.middleware.use` / `insert_before` / `insert_after` |
200
+ | SchemaDslListener | `schema.rb`: `create_table`, `t.string`, `t.index`, `add_foreign_key`, `create_enum` |
201
+ | MigrationDslListener | Migration DSL: `create_table`, `add_column`, `add_index`, `add_reference`, and friends |
202
+ | RoutesDslListener | `config/routes.rb`, resolving namespace/scope/resources nesting into flat routes |
203
+ | MountListener | `mount Sidekiq::Web, at: "/sidekiq"` and the hash form |
204
+ | GemfileDslListener | `gem "name", "version"` and `group :development do ... end` |
205
+ | RakeTaskDslListener | `namespace`, `desc`, `task` in `.rake` files |
206
+ | EnvAccessListener | `ENV["KEY"]`, `ENV.fetch("KEY")`, `ENV.fetch("KEY", default)` |
207
+ | MongoidFieldsListener | Mongoid `field`, embedded relations, custom collection names |
208
+ | MailboxRoutingListener | Action Mailbox `routing` and processing callbacks |
209
+ | ModelReferenceListener | Model constants used in controllers: `Post.find`, `params.require(:post)`, ivar writes |
210
+ | VariantCallListener | `variant` calls (ChainedCallListener with `:variant` preset) |
211
+
212
+ ### Adding a listener
213
+
214
+ 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`.
215
+ 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.
216
+ 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
+ 4. Add a spec of the same name under `spec/lib/rails_ai_context/introspectors/listeners/`.
218
+ 5. Add a row to the table above.
219
+
220
+ ### Choosing between AST and regex
221
+
222
+ Use the AST when the thing you want **is** a Ruby construct: a macro call and its arguments, a method definition, a class and its superclass, an assignment, a constant, a `case`. If you find yourself running a regex over text you already parsed, that is a parse of a parse. Fix it at the node.
223
+
224
+ Regex is the right tool, and stays, for:
225
+
226
+ - **Files that are not Ruby.** `Gemfile.lock`, YAML (`database.yml`, `sidekiq.yml`, fixtures), `structure.sql`, Dockerfiles, ERB, HAML, Slim, JavaScript.
227
+ - **Mixed-extension globs.** A view scan spanning ERB and Phlex `.rb` needs one matcher, or the two halves drift apart.
228
+ - **Vocabulary classification.** "Does this middleware body talk about auth?" is about words, not structure; no node carries it.
229
+ - **Anything the listeners cannot scope.** Tying a call to the enclosing action or `namespace` block needs block scope the listeners do not track, so those fall back to line scanning.
230
+
231
+ Every remaining regex over `.rb` content carries a one-line comment saying which of these it is. If you add one without a reason, convert it instead.
232
+
187
233
  ### Confidence tagging
188
234
 
189
235
  Every AST result carries a confidence tag:
data/docs/SECURITY.md CHANGED
@@ -33,8 +33,9 @@ flowchart LR
33
33
  L1 -->|"Blocked:\nINSERT, DROP,\nUNION SELECT..."| R1[Rejected]
34
34
  L1 -->|SELECT only| L2{Layer 2\nDatabase Read-Only}
35
35
  L2 -->|"SET TRANSACTION\nREAD ONLY\n+ timeout"| L3{Layer 3\nRow Limit}
36
- L3 -->|"Cap: 1000 rows\nDefault: 100"| L4{Layer 4\nColumn Redaction}
37
- L4 -->|"password_digest\napi_key → [REDACTED]"| OK[Safe Result]
36
+ L3 -->|"Cap: 1000 rows\nDefault: 100"| L4{Layer 4\nSensitive Columns}
37
+ L4 -->|"names password_digest,\napi_key, ..."| R1
38
+ L4 -->|"no sensitive column"| OK[Safe Result]
38
39
 
39
40
  style R1 fill:#e74c3c,stroke:#c0392b,color:#fff
40
41
  style OK fill:#27ae60,stroke:#1e8449,color:#fff
@@ -75,13 +76,19 @@ All queries execute inside a transaction, then rollback (even if they could writ
75
76
  - Configurable: `config.query_row_limit` (hard cap: 1000)
76
77
  - Applied as `LIMIT` clause appended to query
77
78
 
78
- ### Layer 4 - Column redaction
79
+ ### Layer 4 - Sensitive column rejection
79
80
 
80
- Sensitive column values are replaced with `[REDACTED]`:
81
+ A query that names a sensitive column is **rejected before execution**, not redacted after it:
81
82
 
82
83
  **Default redacted patterns:** `password_digest`, `encrypted_password`, `password_hash`, `reset_password_token`, `confirmation_token`, `unlock_token`, `otp_secret`, `session_data`, `secret_key`, `api_key`, `api_secret`, `access_token`, `refresh_token`, `jti`
83
84
 
84
- Redaction matches by **name** and **suffix** - `SELECT password_digest AS pd` still redacts `pd` because the column name is tracked through the query result.
85
+ Matching is by name, case-insensitive and word-bounded, against both the defaults above and `config.query_redacted_columns`. `SELECT password_digest AS pd FROM users` is blocked outright: post-execution redaction reads the column names the database returns, which the caller controls through aliases and expressions, so it cannot be relied on.
86
+
87
+ If one of your own columns merely looks sensitive (an `oauth_applications.secret`, say), exempt it by name:
88
+
89
+ ```ruby
90
+ config.query_allowed_columns = %w[secret]
91
+ ```
85
92
 
86
93
  ### Environment guard
87
94
 
@@ -96,15 +103,16 @@ The `rails_search_code` and file-reading tools block access to sensitive files:
96
103
 
97
104
  ### Default patterns
98
105
 
99
- ```
100
- .env*
101
- *.key
102
- *.pem
103
- credentials.yml.enc
104
- master.key
105
- secret_key_base
106
- config/secrets.yml
107
- config/credentials/*
106
+ ```text
107
+ .env .env.*
108
+ config/master.key
109
+ config/credentials.yml.enc config/credentials/*.yml.enc
110
+ config/database.yml config/secrets.yml
111
+ config/cable.yml config/storage.yml
112
+ config/mongoid.yml config/redis.yml
113
+ *.pem *.key *.p12 *.pfx *.jks *.keystore
114
+ **/id_rsa **/id_ed25519 **/id_ecdsa **/id_dsa
115
+ .ssh/* .aws/credentials .aws/config .netrc .pgpass .my.cnf
108
116
  ```
109
117
 
110
118
  ### AI context file exclusions
@@ -158,7 +166,7 @@ File type parameters accept only alphanumeric characters.
158
166
 
159
167
  ## Regex injection prevention
160
168
 
161
- User-supplied regex patterns have a 1-second timeout:
169
+ On Ruby 3.2 and newer, user-supplied regex patterns have a 1-second timeout:
162
170
 
163
171
  ```ruby
164
172
  Regexp.new(pattern, timeout: 1)
@@ -166,6 +174,9 @@ Regexp.new(pattern, timeout: 1)
166
174
 
167
175
  Complex patterns that would cause catastrophic backtracking raise `RegexpError` instead of hanging.
168
176
 
177
+ > [!WARNING]
178
+ > `Regexp.timeout` does not exist on Ruby 3.1, which this gem still supports. There the timeout is skipped, and a pattern crafted to backtrack catastrophically can hang the process serving the tool. If you expose `rails_search_code` to input you do not control, run it on Ruby 3.2 or newer.
179
+
169
180
  ---
170
181
 
171
182
  ## Safe file reading
@@ -207,7 +218,7 @@ When using HTTP transport (Rack middleware or McpController):
207
218
 
208
219
  - **Default bind**: `127.0.0.1` (localhost only - not exposed to network)
209
220
  - **`auto_mount` is `false` by default** - must be explicitly enabled
210
- - **Doctor checks** warn if `auto_mount` is true (security flag)
221
+ - **Doctor checks** fail if `auto_mount` is true in production, and report it as enabled elsewhere
211
222
 
212
223
  The McpController uses thread-safe transport initialization with mutex synchronization.
213
224
 
@@ -0,0 +1,38 @@
1
+ # Domain Docs
2
+
3
+ How the engineering skills should consume this repo's domain documentation when exploring the codebase.
4
+
5
+ This repo is **single-context**: one `CONTEXT.md` and one `docs/adr/` at the root.
6
+
7
+ ## Before exploring, read these
8
+
9
+ - **`CONTEXT.md`** at the repo root: the glossary and domain overview.
10
+ - **`docs/adr/`**: read ADRs that touch the area you're about to work in.
11
+
12
+ If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
13
+
14
+ ## File structure
15
+
16
+ ```text
17
+ /
18
+ ├── CONTEXT.md
19
+ ├── docs/adr/
20
+ │ ├── 0001-example-decision.md
21
+ │ └── 0002-another-decision.md
22
+ ├── lib/
23
+ └── spec/
24
+ ```
25
+
26
+ If this repo ever splits into multiple bounded contexts, add a root `CONTEXT-MAP.md` pointing at one `CONTEXT.md` per context, and update this file.
27
+
28
+ ## Use the glossary's vocabulary
29
+
30
+ When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
31
+
32
+ If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
33
+
34
+ ## Flag ADR conflicts
35
+
36
+ If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
37
+
38
+ > _Contradicts ADR-0007 (event-sourced orders), but worth reopening because..._
@@ -0,0 +1,45 @@
1
+ # Issue tracker: GitHub
2
+
3
+ Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
4
+
5
+ ## Conventions
6
+
7
+ - **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
8
+ - **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
9
+ - **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
10
+ - **Comment on an issue**: `gh issue comment <number> --body "..."`
11
+ - **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
12
+ - **Close**: `gh issue close <number> --comment "..."`
13
+
14
+ Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
15
+
16
+ ## Pull requests as a triage surface
17
+
18
+ **PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
19
+
20
+ When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
21
+
22
+ - **Read a PR**: `gh pr view <number> --comments` and `gh pr diff <number>` for the diff.
23
+ - **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`).
24
+ - **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
25
+
26
+ GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`.
27
+
28
+ ## When a skill says "publish to the issue tracker"
29
+
30
+ Create a GitHub issue.
31
+
32
+ ## When a skill says "fetch the relevant ticket"
33
+
34
+ Run `gh issue view <number> --comments`.
35
+
36
+ ## Wayfinding operations
37
+
38
+ Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
39
+
40
+ - **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`.
41
+ - **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #<map>` at the top of the child body. Labels: `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
42
+ - **Blocking**: GitHub's **native issue dependencies** — the canonical, UI-visible representation. Add an edge with `gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<blocker-db-id>`, where `<blocker-db-id>` is the blocker's numeric **database id** (`gh api repos/<owner>/<repo>/issues/<n> --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only — the live gate). Where dependencies aren't available, fall back to a `Blocked by: #<n>, #<n>` line at the top of the child body. A ticket is unblocked when every blocker is closed.
43
+ - **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins.
44
+ - **Claim**: `gh issue edit <n> --add-assignee @me` — the session's first write.
45
+ - **Resolve**: `gh issue comment <n> --body "<answer>"`, then `gh issue close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far.
@@ -0,0 +1,15 @@
1
+ # Triage Labels
2
+
3
+ The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
4
+
5
+ | Label in mattpocock/skills | Label in our tracker | Meaning |
6
+ | -------------------------- | -------------------- | ---------------------------------------- |
7
+ | `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
8
+ | `needs-info` | `needs-info` | Waiting on reporter for more information |
9
+ | `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
10
+ | `ready-for-human` | `ready-for-human` | Requires human implementation |
11
+ | `wontfix` | `wontfix` | Will not be actioned |
12
+
13
+ When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
14
+
15
+ Edit the right-hand column to match whatever vocabulary you actually use.
@@ -21,7 +21,7 @@ module RailsAiContext
21
21
  sensitive_patterns search_extensions concern_paths frontend_paths extra_app_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
- query_timeout query_row_limit query_redacted_columns allow_query_in_production
24
+ query_timeout query_row_limit query_redacted_columns query_allowed_columns allow_query_in_production
25
25
  log_lines introspectors
26
26
  hydration_enabled hydration_max_hints
27
27
  instrumentation_include_arguments
@@ -244,6 +244,7 @@ module RailsAiContext
244
244
  attr_accessor :query_timeout # Statement timeout in seconds (default: 5)
245
245
  attr_accessor :query_row_limit # Max rows returned (default: 100, hard cap: 1000)
246
246
  attr_accessor :query_redacted_columns # Column names whose values are redacted in output
247
+ attr_accessor :query_allowed_columns # Column names to exempt from the built-in sensitive list
247
248
  attr_accessor :allow_query_in_production # Allow rails_query in production (default: false)
248
249
 
249
250
  # Log reading settings (rails_read_logs)
@@ -317,6 +318,7 @@ module RailsAiContext
317
318
  otp_secret session_data secret_key
318
319
  api_key api_secret access_token refresh_token jti
319
320
  ]
321
+ @query_allowed_columns = []
320
322
  @allow_query_in_production = false
321
323
  @log_lines = 50
322
324
  @hydration_enabled = true
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # The `detail` parameter shared by most tools. Values arrive over the wire as
5
+ # strings, so this stays string-compatible rather than wrapping them; what it
6
+ # adds is one definition of the allowed values and an ordering, so callers can
7
+ # ask "is this at least standard?" instead of comparing literals.
8
+ module DetailLevel
9
+ SUMMARY = "summary"
10
+ STANDARD = "standard"
11
+ FULL = "full"
12
+
13
+ ALL = [ SUMMARY, STANDARD, FULL ].freeze
14
+ DEFAULT = STANDARD
15
+
16
+ ORDER = { SUMMARY => 0, STANDARD => 1, FULL => 2 }.freeze
17
+
18
+ def self.valid?(detail)
19
+ ALL.include?(detail.to_s)
20
+ end
21
+
22
+ # Unknown values read as the default rather than falling through to
23
+ # whichever branch happens to be last.
24
+ def self.normalize(detail)
25
+ valid?(detail) ? detail.to_s : DEFAULT
26
+ end
27
+
28
+ def self.at_least?(detail, minimum)
29
+ ORDER.fetch(normalize(detail)) >= ORDER.fetch(minimum)
30
+ end
31
+
32
+ def self.full?(detail)
33
+ normalize(detail) == FULL
34
+ end
35
+
36
+ def self.summary?(detail)
37
+ normalize(detail) == SUMMARY
38
+ end
39
+ end
40
+ end
@@ -112,6 +112,7 @@ module RailsAiContext
112
112
  next false unless Dir.exist?(dir)
113
113
  Dir.glob(File.join(dir, "**/*.{erb,haml,slim,js,ts,jsx,tsx,mjs,rb}")).any? do |f|
114
114
  next false if File.directory?(f)
115
+ # The glob spans ERB, JS and Ruby, so one text scan covers them all.
115
116
  (RailsAiContext::SafeFile.read(f) || "").match?(/direct.upload|DirectUpload|direct_upload/)
116
117
  end
117
118
  end
@@ -102,6 +102,8 @@ module RailsAiContext
102
102
  # and would produce non-deterministic output on large monorepos.
103
103
  Dir.glob(File.join(dir, "**/*.rb")).sort.first(2000).each do |path|
104
104
  content = RailsAiContext::SafeFile.read(path) or next
105
+ # Mention of the class anywhere counts, so a text scan over 2000
106
+ # files beats parsing each one. Regex stays.
105
107
  next unless content.match?(/MessageEncryptor|MessageVerifier/)
106
108
  relative = path.sub("#{root}/", "")
107
109
  hits << { file: relative, encryptor: content.include?("MessageEncryptor"), verifier: content.include?("MessageVerifier") }