rails-ai-context 5.27.0 → 5.28.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +189 -0
  3. data/docs/ARCHITECTURE.md +2 -2
  4. data/exe/rails-ai-context +13 -4
  5. data/lib/rails_ai_context/action_filters.rb +1 -2
  6. data/lib/rails_ai_context/boot_manager.rb +23 -2
  7. data/lib/rails_ai_context/cli/entry_boot.rb +6 -0
  8. data/lib/rails_ai_context/cli/tool_runner.rb +10 -22
  9. data/lib/rails_ai_context/concern_macros.rb +1 -2
  10. data/lib/rails_ai_context/concern_paths.rb +6 -1
  11. data/lib/rails_ai_context/detail_level.rb +7 -4
  12. data/lib/rails_ai_context/doctor.rb +1 -2
  13. data/lib/rails_ai_context/fingerprinter.rb +1 -2
  14. data/lib/rails_ai_context/hydrators/controller_hydrator.rb +2 -4
  15. data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +1 -1
  16. data/lib/rails_ai_context/hydrators/view_hydrator.rb +1 -2
  17. data/lib/rails_ai_context/install/cleanup.rb +23 -7
  18. data/lib/rails_ai_context/install/program.rb +11 -11
  19. data/lib/rails_ai_context/install_mode.rb +1 -2
  20. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -2
  21. data/lib/rails_ai_context/introspectors/action_resolver.rb +45 -13
  22. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -4
  23. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +4 -8
  24. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +8 -16
  25. data/lib/rails_ai_context/introspectors/api_introspector.rb +7 -15
  26. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +2 -8
  27. data/lib/rails_ai_context/introspectors/auth_introspector.rb +11 -22
  28. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +6 -12
  29. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -15
  30. data/lib/rails_ai_context/introspectors/config_introspector.rb +9 -64
  31. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +5 -10
  32. data/lib/rails_ai_context/introspectors/controller_filters.rb +1 -2
  33. data/lib/rails_ai_context/introspectors/controller_introspector.rb +23 -114
  34. data/lib/rails_ai_context/introspectors/convention_introspector.rb +6 -50
  35. data/lib/rails_ai_context/introspectors/credentials_introspector.rb +3 -6
  36. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +3 -6
  37. data/lib/rails_ai_context/introspectors/declared_constant.rb +2 -4
  38. data/lib/rails_ai_context/introspectors/devops_introspector.rb +35 -56
  39. data/lib/rails_ai_context/introspectors/eager_load.rb +1 -2
  40. data/lib/rails_ai_context/introspectors/engine_introspector.rb +2 -4
  41. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +3 -6
  42. data/lib/rails_ai_context/introspectors/env_introspector.rb +2 -4
  43. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +12 -31
  44. data/lib/rails_ai_context/introspectors/gem_introspector.rb +2 -4
  45. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +48 -48
  46. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +4 -8
  47. data/lib/rails_ai_context/introspectors/job_introspector.rb +66 -27
  48. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +3 -8
  49. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +2 -2
  50. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +3 -6
  51. data/lib/rails_ai_context/introspectors/migration_introspector.rb +2 -4
  52. data/lib/rails_ai_context/introspectors/migration_replay.rb +21 -36
  53. data/lib/rails_ai_context/introspectors/model_introspector.rb +2 -15
  54. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +5 -10
  55. data/lib/rails_ai_context/introspectors/observability_introspector.rb +6 -12
  56. data/lib/rails_ai_context/introspectors/performance_introspector.rb +5 -30
  57. data/lib/rails_ai_context/introspectors/route_introspector.rb +11 -21
  58. data/lib/rails_ai_context/introspectors/schema_introspector.rb +6 -12
  59. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -2
  60. data/lib/rails_ai_context/introspectors/security_introspector.rb +9 -18
  61. data/lib/rails_ai_context/introspectors/source_introspector.rb +1 -2
  62. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +6 -51
  63. data/lib/rails_ai_context/introspectors/table_name.rb +1 -2
  64. data/lib/rails_ai_context/introspectors/test_introspector.rb +20 -46
  65. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +19 -51
  66. data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -10
  67. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +3 -3
  68. data/lib/rails_ai_context/legacy_cleanup.rb +3 -1
  69. data/lib/rails_ai_context/mcp_config_generator.rb +42 -67
  70. data/lib/rails_ai_context/migration_status.rb +1 -2
  71. data/lib/rails_ai_context/package_json.rb +98 -0
  72. data/lib/rails_ai_context/payload.rb +45 -0
  73. data/lib/rails_ai_context/portable_path.rb +1 -2
  74. data/lib/rails_ai_context/redaction.rb +26 -10
  75. data/lib/rails_ai_context/schema_adapter.rb +2 -2
  76. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +6 -34
  77. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +1 -37
  78. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -40
  79. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +41 -0
  80. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -1
  81. data/lib/rails_ai_context/tasks/rails_ai_context.rake +5 -10
  82. data/lib/rails_ai_context/tools/analyze_feature.rb +38 -72
  83. data/lib/rails_ai_context/tools/base_tool.rb +32 -35
  84. data/lib/rails_ai_context/tools/diagnose.rb +2 -4
  85. data/lib/rails_ai_context/tools/generate_test.rb +42 -48
  86. data/lib/rails_ai_context/tools/get_api.rb +1 -6
  87. data/lib/rails_ai_context/tools/get_callbacks.rb +1 -5
  88. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -5
  89. data/lib/rails_ai_context/tools/get_concern.rb +43 -57
  90. data/lib/rails_ai_context/tools/get_context.rb +2 -1
  91. data/lib/rails_ai_context/tools/get_controllers.rb +24 -34
  92. data/lib/rails_ai_context/tools/get_conventions.rb +25 -29
  93. data/lib/rails_ai_context/tools/get_env.rb +39 -93
  94. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -5
  95. data/lib/rails_ai_context/tools/get_helper_methods.rb +58 -51
  96. data/lib/rails_ai_context/tools/get_job_pattern.rb +4 -15
  97. data/lib/rails_ai_context/tools/get_model_details.rb +8 -24
  98. data/lib/rails_ai_context/tools/get_partial_interface.rb +9 -20
  99. data/lib/rails_ai_context/tools/get_routes.rb +1 -5
  100. data/lib/rails_ai_context/tools/get_schema.rb +3 -9
  101. data/lib/rails_ai_context/tools/get_service_pattern.rb +64 -42
  102. data/lib/rails_ai_context/tools/get_stimulus.rb +10 -13
  103. data/lib/rails_ai_context/tools/get_test_info.rb +3 -9
  104. data/lib/rails_ai_context/tools/get_turbo_map.rb +4 -11
  105. data/lib/rails_ai_context/tools/get_view.rb +3 -9
  106. data/lib/rails_ai_context/tools/migration_advisor.rb +2 -4
  107. data/lib/rails_ai_context/tools/onboard.rb +1 -223
  108. data/lib/rails_ai_context/tools/performance_check.rb +28 -35
  109. data/lib/rails_ai_context/tools/runtime_info.rb +4 -11
  110. data/lib/rails_ai_context/tools/search_code.rb +5 -16
  111. data/lib/rails_ai_context/tools/search_docs.rb +1 -2
  112. data/lib/rails_ai_context/tools/section_fetch.rb +1 -2
  113. data/lib/rails_ai_context/tools/security_scan.rb +1 -5
  114. data/lib/rails_ai_context/tools/session_context.rb +1 -2
  115. data/lib/rails_ai_context/tools/validate.rb +1 -2
  116. data/lib/rails_ai_context/tools/validate_semantics.rb +10 -16
  117. data/lib/rails_ai_context/version.rb +1 -1
  118. data/lib/rails_ai_context/vfs.rb +5 -34
  119. data/lib/rails_ai_context.rb +15 -0
  120. data/server.json +1 -1
  121. metadata +2 -2
  122. data/lib/rails_ai_context/serializers/section_guard.rb +0 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44038590682c51bc7722a4a5b587ee31b5b78f3842575b6386390a584038b81e
4
- data.tar.gz: 4c7de07da1f44144291ba86e9bfc34ab3a3da893c0a159b8cfe99de284d3d3be
3
+ metadata.gz: 78848e75315236681b6b31bba337665e75a143d862d2d1ff828f48f50b1756f5
4
+ data.tar.gz: 8af5164b8ee4ea9900d82450d2a74905dcf1b831c02d7ba95c5c869274c18e9e
5
5
  SHA512:
6
- metadata.gz: b581d96cb4ff655823a103c21527a53ddb71c3cb36d644a59485a796693001e247f6480b381d1f54ea8ec0995e175aa0389109fe1155bfbeea2dfeefd5566d9c
7
- data.tar.gz: 61c4aa682dd49afaf780652300f41de2216a0bfbe0b6ed7e6ec188237c63eade5036fe2051ba6771f89e96803a0971811230e532a7c623d7a25d0bfb1f069df8
6
+ metadata.gz: 6dbfa116e75a4675314e6c4a06ada6c2c7d7b8c7f0d9fb5ca0abe9945add65f1729b0101200debf5c25ea2190e66b57f41761b4307d8414d77ccfc62dc292e9d
7
+ data.tar.gz: 86984bc5566480da28d88b7ef475138bb85c9ba459fcd87e58f8a4afd03239a365ee30d032412701291ec0bd60f7f0e0cccfa882fee0150e6baed74c966a33b1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,195 @@ 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.28.0] - 2026-09-22
9
+
10
+ Thirty-six changes an architecture survey of v5.27.0 asked for, nine of them
11
+ defects where a user got a wrong answer, plus what three review rounds found in
12
+ the fixes themselves.
13
+
14
+ ### Added
15
+
16
+ - **Services, helpers, jobs and concerns are found wherever the app keeps
17
+ them.** The four tools globbed `app/<kind>` off the app root, so a packwerk
18
+ app whose services live under `packs/billing/app/services` was told it had no
19
+ services directory and may not use the pattern. Directory discovery reads
20
+ `PathResolver.dirs_for`, which already resolved packs, engines and
21
+ `extra_app_paths` for the payload side. `ConcernPaths.resolve` and
22
+ `ActiveSupportIntrospector` read the same resolver, so a concern's listing,
23
+ its lookup and its "Used By" line now agree on one app. Counts move on any
24
+ app with packs or engines. Each walk names the directory a file came from, so
25
+ two files of the same basename under different roots no longer collide, and a
26
+ tool that finds nothing names all three directory patterns it searched
27
+ instead of the conventional one alone.
28
+ - **`RailsAiContext.debug_fail`.** About 258 rescue bodies in `lib/`
29
+ hand-rolled the same three lines: rescue the error, write a `DEBUG`-gated
30
+ warning to stderr, return a fallback. They call one method now, with every
31
+ message string and every fallback value unchanged. `exe/rails-ai-context`
32
+ shims it beside `log_warn`, because the standalone install path loads seven
33
+ files by hand and a converted rescue inside `install_mode.rb` would otherwise
34
+ raise `NoMethodError` after the removed-tool cleanup had already deleted
35
+ files.
36
+ - **`PackageJson`.** GemLock's twin for the node side: `package.json` parsed
37
+ once per root, `dependencies` merged with `devDependencies`, capped by
38
+ `configuration.max_file_size` rather than by a constant no caller can reach,
39
+ and empty for anything it cannot read. A scope named after the package
40
+ counts, so an app reaching tailwind through `@tailwindcss/vite` keeps its
41
+ answer.
42
+
43
+ ### Removed
44
+
45
+ - **`config.job_processor`, gone from `.ai-context.json` and from
46
+ `get_config`.** `ConfigIntrospector` parsed `config/sidekiq.yml` a second
47
+ time to publish it, and nothing read it: no renderer, no spec, no template.
48
+ The two readers disagreed, so a `:queues:` block written with weights,
49
+ `- [critical, 2]`, lost `critical` on the config side and kept it on the jobs
50
+ side, and one sidekiq.yml shipped two queue lists in one payload. The same
51
+ concurrency and queues stay under `jobs.sidekiq_config`, which
52
+ `get_job_pattern` already consumes, and `config.queue_adapter` still names
53
+ the adapter. Both sections sit in the standard and full presets, so no preset
54
+ loses the fact.
55
+ - **`action_bindings` and `outlet_controllers`, gone from every Stimulus
56
+ controller entry.** Filling `action_bindings` read every ERB, HAML and Slim
57
+ file under `app/views` and `app/components` on every generation, and
58
+ `outlet_controllers` restated the outlets list one line above it. Nothing in
59
+ `lib/` read either key: `get_stimulus` renders outlets, and the compact
60
+ serializer touches the section through `controllers` and `total_controllers`.
61
+ Both keys leave the artifact, and one full pass over the view and component
62
+ trees leaves every generation.
63
+ - **`Serializers::SectionGuard`, `DetailLevel::SCHEMA_ENUM` and
64
+ `Install::Program.select_tool_mode`.** SectionGuard was 15 lines forwarding
65
+ to `Tools::SectionFetch.usable?`; both callers sat in `SchemaAdapter`, which
66
+ is not a serializer, and `RouteCoverage` already called SectionFetch
67
+ directly. SCHEMA_ENUM went with its last reference when
68
+ `DetailLevel.schema(description)` replaced the same four-line input-schema
69
+ hash in 21 tools; every published schema is byte-identical, and
70
+ `rails_onboard` keeps its own literal because its levels are quick, standard
71
+ and full. `select_tool_mode` was a shim for `select_setup(surface).tool_mode`
72
+ with no caller left in `lib/`, and it cannot express the MCP-config-only
73
+ mode. `ModelIntrospector#sti_bases` and
74
+ `ControllerIntrospector#extract_permit_details` go with them; only their own
75
+ recursion and their own specs were calling them.
76
+
77
+ ### Changed
78
+
79
+ - **`.github/instructions/rails-context.instructions.md` prints every gem
80
+ category.** `.first(6)` on the grouped Hash returned the first six pairs, so
81
+ the seventh category onward was dropped with nothing saying so, while
82
+ `.cursor/rules/rails-project.mdc` rendered the same grouping uncapped. One
83
+ payload, two gem lists.
84
+ - **`.cursor/rules/rails-project.mdc` bolds `Global before_actions:`** like the
85
+ Claude and Copilot files. The three serializers each hand-wrote the same
86
+ ten-step overview and had drifted; `StackOverviewHelper#overview_lines`
87
+ states the body once and each renderer keeps only its frontmatter, title and
88
+ MCP hint.
89
+ - **Puma settings are read inside blocks.** The hand-rolled recursion stopped
90
+ at the top level, so a `workers` line guarded by an environment conditional,
91
+ which is what the generated `puma.rb` ships, was invisible and the answer
92
+ read as no workers configured. `MethodCallListener` sees a receiverless call
93
+ at any depth. The cost of the widening is that nesting no longer hides a
94
+ setting, so a name set twice reports the last one wherever it sits. Also
95
+ `threads ENV.fetch("RAILS_MIN_THREADS") { 5 }, ENV.fetch("RAILS_MAX_THREADS")
96
+ { 5 }` reports 5 and 5 where it reported nothing, and the digit match is
97
+ anchored away from word characters, so `ENV.fetch("PORT_2", 3000)` no longer
98
+ reads as 2.
99
+ - **`get_conventions`' frontend stack string reads parsed dependencies.**
100
+ `detect_frontend_stack` matched bare substrings against the raw file, with no
101
+ quotes at all, so `vite-plugin-ruby` on its own reported Vite. The markers
102
+ carry alternates where an exact name would have lost an answer the substring
103
+ caught: svelte or `@sveltejs/kit`, `@hotwired/turbo` or
104
+ `@hotwired/turbo-rails`.
105
+ - **`api_introspector`'s codegen list reads parsed dependencies.** It searched
106
+ the file for the quoted tool name, so `openapi-typescript`, `orval` or
107
+ `@graphql-codegen/cli` named anywhere in `package.json`, an `overrides` block
108
+ included, counted as a client generator the app runs.
109
+ - **`rails_generate_test` resolves a controller by the rule
110
+ `rails_get_controllers` uses.** It camelized the string and looked the result
111
+ up, so with `Admin::GiftCardsController` as the only gift-card controller,
112
+ `rails_get_controllers(controller: "gift_cards")` resolved and
113
+ `rails_generate_test(controller: "gift_cards")` answered "not found".
114
+ `Payload.find_controller` answers for both, with the camelize kept as the
115
+ fallback for a payload carrying no controllers at all.
116
+
117
+ ### Fixed
118
+
119
+ Nine defects the survey confirmed against v5.27.0, and the defects three review
120
+ rounds found in the work that fixed them.
121
+
122
+ - **`onboard(detail: "quick")` no longer prints a guessed domain noun.** Eleven
123
+ ordered regexes read model, job and service names and stated the result as
124
+ plain fact with no confidence marker, so one model named `Message` made an
125
+ app "a messaging app". Quick mode now states what it measured: versions,
126
+ table, model and job counts, the frontend and the test framework.
127
+ - **`api_namespaces` invented a namespace and missed one.** The two tiers
128
+ derived the list their own way. The booted regex kept a trailing slash on
129
+ `/api/users`, matched unanchored so `namespace :admin { namespace :api }`
130
+ reported `/api/v1` for an app that serves `/admin/api/v1`, and dropped a
131
+ route sitting at exactly `/api`. The static spelling got the segment boundary
132
+ wrong, so `/apidocs` reported `/api`. Both tiers read one anchored form.
133
+ - **Turbo drive settings were double-counted for layouts.** `app/views/layouts`
134
+ was walked again after `app/views/**/*` had already reached it. The permanent
135
+ element pass threw its duplicate away through `uniq`; the drive settings did
136
+ not, so one attribute in a layout and one in a view printed as 3.
137
+ - **The asset pipeline reported the wrong bundler and CSS framework.** Three
138
+ readers asked whether a package was present by searching the raw file for its
139
+ quoted name, so an app pinning CVE fixes in an `overrides` block was told
140
+ esbuild is its bundler and postcss its CSS framework, while the one reader
141
+ that parsed the file called the same app vite. Both answers landed in one
142
+ payload.
143
+ - **`analyze_feature`'s Jobs, Mailers and Channels sections came from a glob.**
144
+ Each job's queue was read off its own `queue_as`, so a job inheriting the
145
+ queue from `ApplicationJob` was reported on a queue it does not use. The
146
+ three sections read the payload now, which carries what the booted tier
147
+ resolved, mailer actions and channel stream methods included. A section whose
148
+ payload entry is not there, because the `:jobs` introspector is off, is left
149
+ out rather than rendered from a directory walk.
150
+ - **`performance_check` attributed queries to methods that are not actions.** A
151
+ `def` inside a nested class became a call site, and a one-line
152
+ `private def set_post` leaked its body into the action above it.
153
+ `ActionResolver` answers what an action is here, as it does everywhere else.
154
+ A second collision went with it: each body was found by searching the whole
155
+ file for the first `def <name>`, so a nested class defining the same name
156
+ earlier handed back its body and the real action was never scanned. The body
157
+ is cut from the lines the owner-filtered walk recorded, and `get_controllers`
158
+ and `get_context` share the seam. An empty owner filter falls back to the
159
+ line scan rather than answering nothing, and `def self.index` no longer
160
+ stands in for `def index`.
161
+ - **A model whose only rule is `validate :method` printed its bullets under the
162
+ wrong heading.** `## Validations` was emitted only when the reflected list
163
+ had entries, and the custom bullets come from a second list that is disjoint
164
+ from it on both tiers, so they landed under Associations.
165
+ - **Two ERB readers missed `<%== ... %>` and read a commented ivar as used.**
166
+ `check_instance_variable_usage` and `extract_local_variable_references` each
167
+ carried their own copy of the tag regex, byte for byte the same and neither
168
+ matching `ErbSource::TAG`. Both read `<%[=\-]?`, one optional character, so
169
+ `<%== title %>` parsed as a body of "= title" and the local never reached a
170
+ partial's expected locals. The comment skip was a second answer too:
171
+ `get_partial_interface` re-implemented it and `validate_semantics` never had
172
+ one, so `<%# @ghost %>` was reported as an ivar used in the view and not set
173
+ in the controller. Both readers ask `ErbSource` now.
174
+ - **`rails_get_controllers` refused a name the VFS resource accepts.** VFS
175
+ resolved five ways, the tool two, so `controller: "gift_cards"` failed
176
+ against `Admin::GiftCardsController` while the resource answered off the same
177
+ payload. Both read `Payload.find_controller`, so a route key, an unambiguous
178
+ basename and the singularize and classify spellings now resolve in the tool
179
+ too.
180
+ - **Two files behind one name no longer hide each other.** Once services,
181
+ helpers and concerns were read from packs and engines, a name could have more
182
+ than one file behind it. The ambiguous-service list printed
183
+ `app/services/<path>` for files that live under a pack, so two candidates
184
+ printed as two identical lines naming a file that exists in neither, and the
185
+ suggestion that followed handed back the name it had just refused. Paths
186
+ print from the app root, and a narrowing suggestion is offered only when some
187
+ candidate's relative path is unique. `rails_get_helper_methods` rendered the
188
+ first match as the whole module and listed the others under "Also defined
189
+ in" even when they declare different modules, so a file declaring
190
+ `Reports::DashboardHelper` was named under an `Admin::DashboardHelper`
191
+ heading; matches are split by the module each file declares.
192
+ `rails_get_concern` broke on the first directory that resolved, so an app
193
+ with both `app/models/concerns/trackable.rb` and
194
+ `app/controllers/concerns/trackable.rb` saw only the controller one. Every
195
+ "available" list is deduplicated where it is built.
196
+
8
197
  ## [5.27.0] - 2026-09-22
9
198
 
10
199
  ### Added
data/docs/ARCHITECTURE.md CHANGED
@@ -110,11 +110,12 @@ flowchart LR
110
110
 
111
111
  The `Introspector` orchestrator runs configured introspectors and merges results.
112
112
 
113
- Three modules answer questions every introspector used to answer for itself:
113
+ Four modules answer questions every introspector used to answer for itself:
114
114
 
115
115
  - **SourceScan** - one walk over a kind of app source, across every directory `PathResolver` resolves: `paths` stats, `each` reads, `classes` names by the declared constant
116
116
  - **EagerLoad** - loads a directory's constants for a booted-tier walk, one file at a time, so an unloadable file costs only itself
117
117
  - **GemLock** - which gems the app resolved and at what version, read once per lockfile and matched by exact name across GEM, GIT and PATH
118
+ - **PackageJson** - which npm packages the app depends on, read once per `package.json`: present means named in `dependencies` or `devDependencies`, so an `overrides` pin is not a dependency, and `@tailwindcss/vite` counts as tailwindcss
118
119
 
119
120
  Two more answer a question a tool asks:
120
121
 
@@ -198,7 +199,6 @@ Result: controller and view tools automatically include relevant schema informat
198
199
  | `ToolGuideHelper` | MCP/CLI tool reference sections |
199
200
  | `TestCommandDetection` | Test framework detection |
200
201
  | `SectionFacts` | The facts every surface states about an app - auth, assets, associations, the filter chain, an unread entry's row, the static-tier notice - each rendered in one place |
201
- | `SectionGuard` | Whether a section resolved, so a refused one is not rendered |
202
202
  | `SectionMarkerWriter` | Writes a managed section into a file the user also owns |
203
203
  | `ContextModeDispatch` | Picks full or compact rendering for a run |
204
204
 
data/exe/rails-ai-context CHANGED
@@ -592,11 +592,20 @@ class RailsAiContextCLI < Thor
592
592
  install/program mcp_config_generator legacy_cleanup
593
593
  ].each { |file| require_relative "../lib/rails_ai_context/#{file}" }
594
594
 
595
- return if ::RailsAiContext.respond_to?(:log_warn)
595
+ # Both are defined in the entry file, which is the thing that must not
596
+ # load yet. With no Rails in the process log_warn only writes to stderr.
597
+ unless ::RailsAiContext.respond_to?(:log_warn)
598
+ ::RailsAiContext.define_singleton_method(:log_warn) { |message| $stderr.puts(message) }
599
+ end
600
+
601
+ return if ::RailsAiContext.respond_to?(:debug_fail)
596
602
 
597
- # Defined in the entry file, which is the thing that must not load yet.
598
- # With no Rails in the process it only writes to stderr.
599
- ::RailsAiContext.define_singleton_method(:log_warn) { |message| $stderr.puts(message) }
603
+ # Every rescue in the files above routes through this, so without it a
604
+ # rescue that fires raises NoMethodError over the error it was catching.
605
+ ::RailsAiContext.define_singleton_method(:debug_fail) do |error, fallback = nil, label:|
606
+ $stderr.puts("[rails-ai-context] #{label} failed: #{error.message}") if ENV["DEBUG"]
607
+ fallback
608
+ end
600
609
  end
601
610
  end
602
611
 
@@ -345,8 +345,7 @@ module RailsAiContext
345
345
  end
346
346
  end.uniq { |skip| skip[:name] }
347
347
  rescue => e
348
- $stderr.puts "[rails-ai-context] ActionFilters skip_source_records failed: #{e.message}" if ENV["DEBUG"]
349
- []
348
+ RailsAiContext.debug_fail(e, [], label: "ActionFilters skip_source_records")
350
349
  end
351
350
 
352
351
  # ApplicationController is deliberately not in the listing: it would sit in
@@ -39,11 +39,32 @@ module RailsAiContext
39
39
  status == :booted
40
40
  end
41
41
 
42
- # One-line summary safe to relay to an AI client or a terminal.
42
+ # Bundler::GemRequireError names the gem it failed to require and
43
+ # nothing about why; the incompatibility that actually raised is in
44
+ # `cause`. Walk to the deepest one, capped so a cycle cannot hang.
45
+ def root_cause
46
+ cause = error&.cause
47
+ 10.times do
48
+ break unless cause&.cause
49
+
50
+ cause = cause.cause
51
+ end
52
+ cause
53
+ end
54
+
55
+ # One-line summary safe to relay to an AI client or a terminal. It
56
+ # carries the cause too: the footer this feeds is the only place most
57
+ # callers ever see, and a gem name with no reason is not actionable.
43
58
  def failure_summary
44
59
  return nil if booted?
45
60
 
46
- "#{error.class}: #{error.message.to_s.lines.first&.strip}"
61
+ summary = one_line(error)
62
+ cause = root_cause
63
+ cause ? "#{summary} (cause: #{one_line(cause)})" : summary
64
+ end
65
+
66
+ def one_line(e)
67
+ "#{e.class}: #{e.message.to_s.lines.first&.strip}"
47
68
  end
48
69
 
49
70
  # An unguarded `RailsAiContext.configure` in config/initializers has
@@ -129,6 +129,12 @@ module RailsAiContext
129
129
  messages << " #{result.failure_summary}"
130
130
  if ENV["DEBUG"]
131
131
  Array(result.error.backtrace).first(15).each { |line| messages << " #{line}" }
132
+ # The wrapper's frames stop at the require; the frames that name the
133
+ # incompatible call are the cause's.
134
+ if (cause = result.root_cause)
135
+ messages << " Raised by:"
136
+ Array(cause.backtrace).first(15).each { |line| messages << " #{line}" }
137
+ end
132
138
  else
133
139
  messages << " Run with DEBUG=1 for the full backtrace."
134
140
  end
@@ -218,9 +218,9 @@ module RailsAiContext
218
218
  # `--files=a.rb b.rb` is the same call as `--files a.rb b.rb`;
219
219
  # honouring only one of the two spellings docs/CLI.md teaches
220
220
  # left the other still dropping every file after the first.
221
- trailing = collect_array_values(args, i + 1)
221
+ trailing, consumed = array_values(args, i + 1)
222
222
  result[key] = Array(coerce_value(value, prop, key)) + trailing
223
- i += 1 + values_consumed(args, i + 1)
223
+ i += 1 + consumed
224
224
  next
225
225
  end
226
226
  result[key] = coerce_value(value, prop, key)
@@ -244,21 +244,12 @@ module RailsAiContext
244
244
  next
245
245
  end
246
246
 
247
- if prop[:type] == "array"
248
- values = collect_array_values(args, i + 1)
249
- unless values.empty?
250
- result[key] = values
251
- i += 1 + values_consumed(args, i + 1)
252
- next
253
- end
254
- end
255
-
256
247
  # A bare `--files` with nothing after it is a mistake, not a
257
248
  # request for `files: true` - an array param holding a Boolean
258
249
  # reaches the tool as a type it never accepts.
259
250
  if prop[:type] == "array"
260
- result[key] = []
261
- i += 1
251
+ result[key], consumed = array_values(args, i + 1)
252
+ i += 1 + consumed
262
253
  next
263
254
  end
264
255
 
@@ -335,15 +326,12 @@ module RailsAiContext
335
326
  # stopping at a rake-style `key=value` token so `--include a model=Post`
336
327
  # does not swallow the second parameter. Each token is still
337
328
  # comma-split, so the documented `a.rb,b.rb` form keeps working and
338
- # mixing the two spellings does not fabricate a path.
339
- def collect_array_values(args, from)
340
- args[from..].to_a
341
- .take_while { |a| !a.start_with?("--") && !a.include?("=") }
342
- .flat_map { |a| Array(coerce_value(a, { type: "array" })) }
343
- end
344
-
345
- def values_consumed(args, from)
346
- args[from..].to_a.take_while { |a| !a.start_with?("--") && !a.include?("=") }.size
329
+ # mixing the two spellings does not fabricate a path. The token count
330
+ # comes back with the values because the cursor advances by tokens: a
331
+ # comma token yields more values than it consumed.
332
+ def array_values(args, from)
333
+ tokens = args[from..].to_a.take_while { |a| !a.start_with?("--") && !a.include?("=") }
334
+ [ tokens.flat_map { |a| Array(coerce_value(a, { type: "array" })) }, tokens.size ]
347
335
  end
348
336
 
349
337
  # Coerce a string value to the type specified in the JSON Schema property.
@@ -88,8 +88,7 @@ module RailsAiContext
88
88
  # A permission bit, a directory in place of a file and a bug in a
89
89
  # listener all land in `unresolved` alike, so the cause is worth
90
90
  # saying where the booted walk already says it.
91
- $stderr.puts "[rails-ai-context] concern introspection failed for #{path}: #{e.message}" if ENV["DEBUG"]
92
- nil
91
+ RailsAiContext.debug_fail(e, nil, label: "concern introspection of #{path}")
93
92
  end
94
93
 
95
94
  def tagged(entry, concern_name)
@@ -23,7 +23,12 @@ module RailsAiContext
23
23
  configured = RailsAiContext.configuration.concern_paths
24
24
  dirs =
25
25
  if configured.nil?
26
- Dir.glob(File.join(root, "app", "*", "concerns"))
26
+ # Every app tree the app has, not just the one at the root: a
27
+ # packwerk pack or an in-repo engine keeps its own app/*/concerns.
28
+ # `dirs_for` cannot take the glob directly - it tests each candidate
29
+ # with `Dir.exist?`, which a literal `app/*/concerns` never passes -
30
+ # so the app trees are resolved first and globbed here.
31
+ PathResolver.dirs_for(root, "app").flat_map { |app_dir| Dir.glob(File.join(app_dir, "*", "concerns")) }
27
32
  else
28
33
  # A path that is already absolute is taken as given; `File.join`
29
34
  # would graft it onto the root and point at nothing.
@@ -15,10 +15,13 @@ module RailsAiContext
15
15
 
16
16
  ORDER = { SUMMARY => 0, STANDARD => 1, FULL => 2 }.freeze
17
17
 
18
- # The enum tools publish in their input schema, so the advertised values
19
- # and the normalizer cannot drift apart. Spelling the values in a tool
20
- # instead fails the enum-ownership spec.
21
- SCHEMA_ENUM = ALL
18
+ # The `detail` property tools publish in their input schema. Tools pass
19
+ # their own wording and get the type and the enum from here, so the
20
+ # advertised values and the normalizer cannot drift apart. Spelling the
21
+ # values in a tool instead fails the enum-ownership spec.
22
+ def self.schema(description)
23
+ { type: "string", enum: ALL, description: description }
24
+ end
22
25
 
23
26
  def self.valid?(detail)
24
27
  ALL.include?(detail.to_s)
@@ -444,8 +444,7 @@ module RailsAiContext
444
444
  fix: introspector_failure_hint(failures))
445
445
  end
446
446
  rescue StandardError, ScriptError => e
447
- $stderr.puts "[rails-ai-context] check_introspector_health failed: #{e.message}" if ENV["DEBUG"]
448
- nil
447
+ RailsAiContext.debug_fail(e, nil, label: "check_introspector_health")
449
448
  end
450
449
 
451
450
  MAX_SHOWN_FAILURES = 3
@@ -156,8 +156,7 @@ module RailsAiContext
156
156
  return false unless spec
157
157
  spec.source.is_a?(Bundler::Source::Path)
158
158
  rescue => e
159
- $stderr.puts "[rails-ai-context] local_gem_path? failed: #{e.message}" if ENV["DEBUG"]
160
- false
159
+ RailsAiContext.debug_fail(e, false, label: "local_gem_path?")
161
160
  end
162
161
  end
163
162
  end
@@ -16,8 +16,7 @@ module RailsAiContext
16
16
 
17
17
  ModelHints.resolve(model_names, context: context)
18
18
  rescue => e
19
- $stderr.puts "[rails-ai-context] ControllerHydrator failed: #{e.message}" if ENV["DEBUG"]
20
- HydrationResult.new
19
+ RailsAiContext.debug_fail(e, HydrationResult.new, label: "ControllerHydrator")
21
20
  end
22
21
 
23
22
  # Detect model names referenced in a controller source file using Prism AST.
@@ -28,8 +27,7 @@ module RailsAiContext
28
27
  Introspectors::ListenerRegistration.dispatcher_for(listener).dispatch(parse_result.value)
29
28
  listener.results
30
29
  rescue => e
31
- $stderr.puts "[rails-ai-context] detect_model_references failed: #{e.message}" if ENV["DEBUG"]
32
- []
30
+ RailsAiContext.debug_fail(e, [], label: "detect_model_references")
33
31
  end
34
32
  private_class_method :detect_model_references
35
33
  end
@@ -13,7 +13,7 @@ module RailsAiContext
13
13
  schema_data = context[:schema]
14
14
  return nil unless models_data.is_a?(Hash) && schema_data.is_a?(Hash)
15
15
 
16
- model_key = Tools::BaseTool.fuzzy_find_key(models_data.keys, model_name)
16
+ model_key = Payload.fuzzy_find_key(models_data.keys, model_name)
17
17
  return nil unless model_key
18
18
 
19
19
  model_info = models_data[model_key]
@@ -17,8 +17,7 @@ module RailsAiContext
17
17
  ModelHints.resolve(model_names, context: context,
18
18
  describe: ->(name) { "@#{name.underscore} used in view but '#{name}' model not found" })
19
19
  rescue => e
20
- $stderr.puts "[rails-ai-context] ViewHydrator failed: #{e.message}" if ENV["DEBUG"]
21
- HydrationResult.new
20
+ RailsAiContext.debug_fail(e, HydrationResult.new, label: "ViewHydrator")
22
21
  end
23
22
 
24
23
  # Convert an instance variable name to a model name by convention.
@@ -15,26 +15,42 @@ module RailsAiContext
15
15
  # @param keeping [Array<Symbol>] the tools still selected, whose files
16
16
  # must survive even when a dropped tool names the same path
17
17
  # @param root [String, Pathname] project root
18
- # @return [Array<String>] the paths removed, directories marked with a
19
- # trailing slash so callers can print them the way they always have
18
+ # @return [Hash] { removed: [paths], failed: [paths] }, directories
19
+ # marked with a trailing slash so callers can print them the way they
20
+ # always have. Both rm calls swallow their errors, so a path is only
21
+ # reported removed once it is gone.
20
22
  def remove(tools:, keeping:, root:)
21
23
  kept = Array(keeping).flat_map { |key| AiTool.find(key)&.context_paths || [] }.to_set
24
+ removed = []
25
+ failed = []
22
26
 
23
- Array(tools).flat_map { |key|
27
+ Array(tools).each do |key|
24
28
  paths = AiTool.find(key)&.context_paths || []
25
- paths.filter_map { |relative| remove_path(relative, root) unless kept.include?(relative) }
26
- }
29
+ paths.each do |relative|
30
+ next if kept.include?(relative)
31
+
32
+ label, gone = remove_path(relative, root)
33
+ next unless label
34
+
35
+ (gone ? removed : failed) << label
36
+ end
37
+ end
38
+
39
+ { removed: removed, failed: failed }
27
40
  end
28
41
 
42
+ # @return [Array(String, Boolean), nil] the path's label and whether it
43
+ # is gone, or nil when there was nothing there to remove. A directory
44
+ # whose children went but which itself survives counts as not gone.
29
45
  def remove_path(relative, root)
30
46
  full = File.join(root.to_s, relative)
31
47
 
32
48
  if File.directory?(full)
33
49
  FileUtils.rm_rf(full)
34
- "#{relative}/"
50
+ [ "#{relative}/", !File.exist?(full) ]
35
51
  elsif File.exist?(full)
36
52
  FileUtils.rm_f(full)
37
- relative
53
+ [ relative, !File.exist?(full) ]
38
54
  end
39
55
  end
40
56
  private_class_method :remove_path
@@ -72,13 +72,6 @@ module RailsAiContext
72
72
  setup
73
73
  end
74
74
 
75
- # The tool-mode half of the same question, for a caller that predates
76
- # the setup struct. It cannot express "MCP config only", so every
77
- # entry point in this gem calls `select_setup` instead.
78
- def select_tool_mode(surface)
79
- select_setup(surface).tool_mode
80
- end
81
-
82
75
  def setup_label(setup)
83
76
  return "MCP config only (no context files)" unless setup.context_files
84
77
 
@@ -118,14 +111,15 @@ module RailsAiContext
118
111
  to_remove.each do |key|
119
112
  tool = AiTool.find(key)
120
113
 
121
- removed_paths = Cleanup.remove(tools: [ key ], keeping: selected.map(&:to_sym), root: root)
122
- removed_paths.each { |path| surface.say " Removed #{path}", :warn }
114
+ outcome = Cleanup.remove(tools: [ key ], keeping: selected.map(&:to_sym), root: root)
115
+ outcome[:removed].each { |path| surface.say " Removed #{path}", :ok }
116
+ outcome[:failed].each { |path| surface.say "Could not remove #{path} - check its permissions", :warn }
123
117
 
124
118
  # Merge-safe MCP config cleanup - removes only the rails-ai-context entry
125
119
  cleaned = RailsAiContext::McpConfigGenerator.remove(tools: [ key ], output_dir: root.to_s)
126
- cleaned.each { |f| surface.say " Removed MCP entry from #{relative_to(f, root)}", :warn }
120
+ cleaned.each { |f| surface.say " Removed MCP entry from #{relative_to(f, root)}", :ok }
127
121
 
128
- surface.say " #{tool.name} files removed", :ok if tool
122
+ surface.say " #{tool.name} files removed", :ok if tool && outcome[:failed].empty?
129
123
  end
130
124
  end
131
125
 
@@ -147,6 +141,9 @@ module RailsAiContext
147
141
 
148
142
  File.open(gitignore, "a") { |f| lines.each { |line| f.puts line } }
149
143
  surface.say "Updated .gitignore", :ok
144
+ rescue SystemCallError, IOError => e
145
+ RailsAiContext.log_warn "[rails-ai-context] could not write .gitignore: #{e.message}"
146
+ surface.say "Could not update .gitignore - add .ai-context.json and .codex/config.toml by hand", :warn
150
147
  end
151
148
 
152
149
  # `standalone: nil` lets the generator detect the install mode from
@@ -159,6 +156,9 @@ module RailsAiContext
159
156
  result = generator.call
160
157
  result[:written].each { |f| surface.say "Created/Updated #{relative_to(f, root)}", :ok }
161
158
  result[:skipped].each { |f| surface.say "#{relative_to(f, root)} unchanged - skipped", :muted }
159
+ result[:failed].each do |f|
160
+ surface.say "Could not write #{relative_to(f, root)} - that tool will not auto-discover the MCP server", :warn
161
+ end
162
162
  surface.say "Skipped MCP config files (CLI-only mode)", :muted if tool_mode == :cli
163
163
  result
164
164
  end
@@ -28,8 +28,7 @@ module RailsAiContext
28
28
  lock = RailsAiContext::GemLock.for(root)
29
29
  lock.missing? ? false : !lock.present?("rails-ai-context")
30
30
  rescue => e
31
- $stderr.puts "[rails-ai-context] standalone install detection failed: #{e.message}" if ENV["DEBUG"]
32
- false
31
+ RailsAiContext.debug_fail(e, false, label: "standalone install detection")
33
32
  end
34
33
  end
35
34
  end
@@ -51,8 +51,7 @@ module RailsAiContext
51
51
  entry[:callbacks] = callbacks if callbacks.any?
52
52
  entry
53
53
  rescue => e
54
- $stderr.puts "[rails-ai-context] extract_mailboxes failed: #{e.message}" if ENV["DEBUG"]
55
- nil
54
+ RailsAiContext.debug_fail(e, nil, label: "extract_mailboxes")
56
55
  end.compact.sort_by { |m| m[:name] }
57
56
  end
58
57
  end