rails-ai-context 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d0ee652e69687c50687cd1b13209688aa5eecc24cf892fa807f9a3d03c7f2de
4
- data.tar.gz: e689153ebb668ead61eb375c551a4724c73ce9338ae78a48b6acae63b9a63266
3
+ metadata.gz: aeeb02e9069ff67b12236ae2f99f169df5b5dd48d42af344ad0734ff1a499307
4
+ data.tar.gz: 0d5f18c57c512747bc06fc063c9e58c95073f288df15f3d74bd0d951815fabe8
5
5
  SHA512:
6
- metadata.gz: 415542e44483875ce828fb3476d7d76a84d3df8a3ad3b4a8e512b649563e74bbc570244c5669d16fad7af574d2eb60c8e07fc743dc0126cfcdb4d728fd5b55f5
7
- data.tar.gz: e8bc26c2edf09e86eee60b70adeee830b4ed9df130a5da5b658f721077d50cb21d496e677fae0dec7059d7416192b9c0ed27727f786ef76a2522aa63be16a025
6
+ metadata.gz: dc0c90e9d22ff8348ce6eefc770ca36eac77bda624795be2813972de0f56b565180bfc974042cc7fa949718a8362c760d3db9123dc8aab294403ca9498efd8f3
7
+ data.tar.gz: f33d929cf59fdd217d4905a49b5043b689cb7828588ff738d9973d2e97c42f78228327d3574d276180e04663cac3f3470c7b84bc118022d9dc8890a47ff117ed
data/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ 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
+ ## [1.3.0] - 2026-03-23
9
+
10
+ ### Added
11
+
12
+ - **Full-stack `analyze_feature` tool** — now discovers services (AF1), jobs with queue/retry config (AF2), views with partial/Stimulus refs (AF3), Stimulus controllers with targets/values/actions (AF4), test files with counts (AF5), related models via associations (AF6), concern tracing (AF12), callback chains (AF13), channels (AF10), mailers (AF11), and environment variable dependencies (AF9). One call returns the complete feature picture.
13
+ - **Modal pattern extraction** (DS1) — detects overlay (`fixed inset-0 bg-black/50`) and modal card patterns
14
+ - **List item pattern extraction** (DS5) — detects repeating card/item patterns from views
15
+ - **Shared partials with descriptions** (DS7) — scans `app/views/shared/` and infers purpose (flash, navbar, status badge, loading, modal, etc.)
16
+ - **"When to use what" decision guide** (DS8) — explicit rules: primary button for CTAs, danger for destructive, when to use shared partials
17
+ - **Bootstrap component extraction** (DS13-DS15) — detects `btn-primary`, `card`, `modal`, `form-control`, `badge`, `alert`, `nav` patterns from Bootstrap apps
18
+ - **Tailwind `@apply` directive parsing** (DS16) — extracts named component classes from CSS `@apply` rules
19
+ - **DaisyUI/Flowbite/Headless UI detection** (DS17) — reports Tailwind plugin libraries from package.json
20
+ - **Animation/transition inventory** (DS19) — extracts `transition-*`, `duration-*`, `animate-*`, `ease-*` patterns
21
+ - **Smarter JSONB strong params check** (V1) — only skips params matching JSON column names, validates the rest
22
+ - **Route-action fix suggestions** (V2) — suggests "add `def action; end`" when route exists but action is missing
23
+
24
+ ### Fixed
25
+
26
+ - **`self` filtered from class methods** (B2/MD1) — no longer appears in model class method lists
27
+ - **Rules serializer methods cap raised to 20** (RS1) — uses introspector's pre-filtered methods directly instead of redundant re-filtering
28
+ - **oklch token noise filtered** (DS21) — complex color values (oklch, calc, var) hidden from summary, only shown in `detail:"full"`
29
+
8
30
  ## [1.2.1] - 2026-03-23
9
31
 
10
32
  ### Fixed
data/ROADMAP.md ADDED
@@ -0,0 +1,148 @@
1
+ # rails-ai-context — Bugs & Improvements Roadmap
2
+
3
+ > Comprehensive list from 6 testing sessions, ~500+ MCP calls, 522 specs, verified on DailyContentChef (9 tables, 6 models, 18 controllers, 15 Stimulus controllers).
4
+ > Current version: v1.2.1 (updated 2026-03-23)
5
+
6
+ ---
7
+
8
+ ## Open Bugs
9
+
10
+ ### MCP Tools
11
+
12
+ | # | Bug | Severity | Tool | Status |
13
+ |---|-----|----------|------|--------|
14
+ | B1 | `unless: :devise_controller?` not fully evaluated — OmniauthCallbacksController shows `authenticate_user!` | LOW | controllers | **FIXED v1.2.1** — evaluates condition at introspection time, removes filter for Devise controllers |
15
+ | B2 | `self` appears in class methods list — Plan shows `self` as a class method alongside `free`, `pro`, `business` | LOW | model_details | Open |
16
+
17
+ ### Rules Serializer (generated CLAUDE.md / rules files)
18
+
19
+ | # | Bug | Severity | Status |
20
+ |---|-----|----------|--------|
21
+ | R1 | User methods list shows ~5 of 18 — missing concern + model-defined methods | MEDIUM | **FIXED v1.2.1** — source-defined methods prioritized, Devise methods filtered |
22
+ | R3 | `visuals_needed` shown as `string` not `string[]` in rules | MEDIUM | **FIXED v1.2.1** — array columns now render as `type[]` |
23
+ | R4 | payments missing `paymongo_checkout_id` and `paymongo_payment_id` columns in rules | MEDIUM | **FIXED v1.2.1** — external ID columns no longer hidden |
24
+ | R5 | users missing `paymongo_customer_id` column in rules | MEDIUM | **FIXED v1.2.1** — same fix as R4 |
25
+ | R6 | No column defaults shown in generated rules | MEDIUM | **FIXED v1.2.1** — defaults shown inline as `(=value)` |
26
+
27
+ ---
28
+
29
+ ## Improvements: `rails_analyze_feature`
30
+
31
+ ### Tier 1 — Core (makes the tool 10x more useful)
32
+
33
+ | # | Improvement | Description | Impact |
34
+ |---|-------------|-------------|--------|
35
+ | AF1 | **Services discovery** | Scan `app/services/` for classes matching the feature keyword. Show class name, line count, key method names. `feature:"cook"` → finds `ContentChefService`, `GeminiClient`, `OutputParser` | HIGH |
36
+ | AF2 | **Jobs discovery** | Scan `app/jobs/` for matching classes. Show queue name, retry config, what service it calls. `feature:"cook"` → finds `CookJob` (queue: default, retries: 3, calls ContentChefService) | HIGH |
37
+ | AF3 | **Views + partials discovery** | List matching views with line counts, partial renders, and Stimulus controller references. `feature:"cook"` → shows `cooks/show.html.erb (61 lines) renders: cooks/output, cooks/loading stimulus: cook-status, share` | HIGH |
38
+ | AF4 | **Stimulus controllers discovery** | Match Stimulus controllers by name. Show targets, values, actions. `feature:"cook"` → finds `cook_status` controller with `cookId` value and `checkTimeout` action | HIGH |
39
+ | AF5 | **Test files discovery** | List matching test files with test counts. `feature:"cook"` → shows `test/models/cook_test.rb (13 tests)`, `test/controllers/cooks_controller_test.rb (21 tests)` | HIGH |
40
+
41
+ ### Tier 2 — Cross-cutting intelligence
42
+
43
+ | # | Improvement | Description | Impact |
44
+ |---|-------------|-------------|--------|
45
+ | AF6 | **Related models via associations** | Show models connected through `belongs_to`, `has_many`. `feature:"cook"` → "Related: User (owner), BrandProfile (optional), CookShare (shares)" | MEDIUM |
46
+ | AF7 | **Execution flow graph** | Trace the full request lifecycle: controller action → authorization check → model operation → job enqueue → service call → external API → broadcast. No other tool does this. | MEDIUM |
47
+ | AF8 | **Permission/authorization mapping** | Map which concern methods guard which actions. `can_cook?` guards `CooksController#create`, `can_use_bonus_modes?` guards `Bonus::BaseController` | MEDIUM |
48
+ | AF9 | **Environment dependencies** | Detect ENV vars referenced by the feature. `feature:"cook"` → requires `GEMINI_API_KEY`, Sidekiq running, Redis connected | MEDIUM |
49
+ | AF10 | **Channel/websocket discovery** | Find `turbo_stream_from` and Action Cable subscriptions. `feature:"cook"` → uses `turbo_stream_from "cook_#{id}"` for real-time output | MEDIUM |
50
+
51
+ ### Tier 3 — Agent workflow optimization
52
+
53
+ | # | Improvement | Description | Impact |
54
+ |---|-------------|-------------|--------|
55
+ | AF11 | **Mailer/notification discovery** | Scan `app/mailers/` for matching classes and their delivery triggers | LOW |
56
+ | AF12 | **Concern tracing** | When a feature uses concerns, list which concerns and their methods. `feature:"User"` → PlanLimitable adds 12 methods | LOW |
57
+ | AF13 | **Callback chains** | Show before/after hooks that fire. `feature:"brand"` → `before_save :ensure_single_default` on BrandProfile | LOW |
58
+ | AF14 | **"How to extend" hints** | Based on existing patterns, suggest where to add a new action, new validation, new partial. "To add a new cook mode: add to MODES constant (line 7), add view in bonus/" | LOW |
59
+
60
+ ---
61
+
62
+ ## Improvements: `rails_get_design_system`
63
+
64
+ ### Tier 1 — Missing component patterns
65
+
66
+ | # | Improvement | Description | Impact |
67
+ |---|-------------|-------------|--------|
68
+ | DS1 | **Modal pattern** | Extract overlay + card pattern from `_share_modal.html.erb`. Show: `fixed inset-0 bg-black/50 z-40` overlay + `bg-white rounded-2xl shadow-lg max-w-md w-full p-6` card | HIGH |
69
+ | DS2 | **Badge/tag pattern** | Extract from mode badges: `text-xs font-medium px-2.5 py-1 rounded-full bg-{color}-100 text-{color}-700`. Show color variants (indigo, green, yellow, red) | HIGH |
70
+ | DS3 | **Status indicator pattern** | Extract from `_status_badge.html.erb`. Show as a reusable shared partial reference: `render "shared/status_badge", cook: cook` | HIGH |
71
+ | DS4 | **Flash/toast patterns** | Extract from `_flash.html.erb`. Show success (`bg-green-50 border-green-200 text-green-700`), error (`bg-red-50 border-red-200 text-red-700`), notice variants | HIGH |
72
+ | DS5 | **List item pattern** | Extract the repeating card-per-item layout from cook index: `bg-white rounded-xl p-5 shadow-sm border border-gray-100 flex items-center justify-between gap-4` | HIGH |
73
+ | DS6 | **Secondary button** | Extract: `bg-gray-100 text-gray-700 px-4 py-2 rounded-xl text-sm font-semibold hover:bg-gray-200 transition cursor-pointer`. Currently only primary + danger listed | MEDIUM |
74
+ | DS7 | **Shared partials section** | List all `app/views/shared/` partials with one-line descriptions. Agents should reuse these before creating new markup: `_flash.html.erb`, `_navbar.html.erb`, `_status_badge.html.erb`, `_upgrade_nudge.html.erb` | MEDIUM |
75
+
76
+ ### Tier 2 — Decision guidance
77
+
78
+ | # | Improvement | Description | Impact |
79
+ |---|-------------|-------------|--------|
80
+ | DS8 | **"When to use what" decision guide** | Not just class strings but rules: "Page needs a form? → Copy Form Page example. Need confirmation? → `data: { turbo_confirm: 'message' }`. Showing status? → `render 'shared/status_badge'`" | HIGH |
81
+ | DS9 | **Loading/spinner pattern** | Extract from `_loading.html.erb`: `animate-spin` emoji + progress bar (`bg-orange-500 h-2 rounded-full animate-pulse`) | MEDIUM |
82
+ | DS10 | **Confirmation dialog convention** | Document the Turbo Confirm pattern: `data: { turbo_confirm: "Are you sure?" }` on `button_to` for destructive actions | MEDIUM |
83
+ | DS11 | **Form error pattern** | Show what validation errors look like: field highlighting, error message placement, `field_with_errors` wrapper behavior | MEDIUM |
84
+ | DS12 | **Spacing system rules** | Explain WHEN to use each spacing: `space-y-3` for list items, `space-y-4` for form fields, `space-y-6` for form sections, `gap-2` for button groups, `mb-6` for section separators | LOW |
85
+
86
+ ### Tier 3 — Framework adaptability
87
+
88
+ | # | Improvement | Description | Impact |
89
+ |---|-------------|-------------|--------|
90
+ | DS13 | **Auto-detect CSS framework** | Detect Tailwind vs Bootstrap vs custom CSS/Sass. Adapt extraction strategy per framework. Currently hardcoded for Tailwind — broken for all other setups | HIGH |
91
+ | DS14 | **Bootstrap support** | Scan ERB for Bootstrap classes (`btn-primary`, `card`, `modal`, `form-control`). Parse `_variables.scss` for custom theme. Show Bootstrap component examples from actual views | HIGH (for Bootstrap apps) |
92
+ | DS15 | **Custom CSS/Sass support** | Parse `.scss/.css` files for class definitions. Group by file (buttons.scss → Button patterns). Detect BEM naming. Show CSS custom properties (`--color-primary`) | HIGH (for custom apps) |
93
+ | DS16 | **Parse Tailwind `@apply` directives** | If app has `@apply` rules in CSS, extract those as named component classes | MEDIUM |
94
+ | DS17 | **Detect DaisyUI / Flowbite / Headless UI** | If Tailwind plugin libraries are installed, include their component patterns alongside raw Tailwind | MEDIUM |
95
+ | DS18 | **Parse `tailwind.config.js` custom theme** | Extract custom colors, fonts, spacing from the config file. Show `primary: '#FF6B00'` if customized | MEDIUM |
96
+ | DS19 | **Animation/transition inventory** | List all `transition`, `animate-*`, `duration-*` patterns with usage context | LOW |
97
+ | DS20 | **Icon size conventions** | Document when to use which size: `w-3.5 h-3.5` (inline with text), `w-4 h-4` (buttons), `w-5 h-5` (standalone), `w-10 h-10` (feature icons) | LOW |
98
+ | DS21 | **Remove oklch noise from summary** | Token colors (oklch values) waste tokens in summary. Move to `detail:"full"` only. Summary should show Tailwind class names only | LOW |
99
+
100
+ ---
101
+
102
+ ## Improvements: Rules Serializer
103
+
104
+ | # | Improvement | Description | Impact |
105
+ |---|-------------|-------------|--------|
106
+ | RS1 | **Include all concern methods** | User methods list should include all PlanLimitable methods (12+), not just 5 | HIGH — partially fixed in v1.2.1 (source methods prioritized), full concern method extraction still open |
107
+ | RS2 | **Detect array columns** | Show `visuals_needed:string[]` not `visuals_needed:string` | ~~MEDIUM~~ **FIXED v1.2.1** |
108
+ | RS3 | **Include all non-system columns** | payments should show `paymongo_checkout_id`, `paymongo_payment_id`. users should show `paymongo_customer_id` | ~~MEDIUM~~ **FIXED v1.2.1** |
109
+ | RS4 | **Show column defaults** | Inline defaults: `mode:string(default:"standard")`, `status:string(default:"pending")` | ~~MEDIUM~~ **FIXED v1.2.1** |
110
+
111
+ ---
112
+
113
+ ## Improvements: `rails_validate`
114
+
115
+ | # | Improvement | Description | Impact |
116
+ |---|-------------|-------------|--------|
117
+ | V1 | **Smarter JSONB strong params skip** | Currently skips ALL params check for models with ANY JSONB column. Could be smarter: only skip params matching JSONB column names, check the rest | LOW |
118
+ | V2 | **Route-action check suggests fix** | When `show` action missing but route exists, suggest: "Add `def show; end` to BrandProfilesController or remove `show` from `resources :brand_profiles`" | LOW |
119
+
120
+ ---
121
+
122
+ ## Improvements: `rails_get_model_details`
123
+
124
+ | # | Improvement | Description | Impact |
125
+ |---|-------------|-------------|--------|
126
+ | MD1 | **Filter `self` from class methods** | Plan shows `self` as a class method — should be filtered out | LOW |
127
+
128
+ ---
129
+
130
+ ## Summary
131
+
132
+ | Category | Total | Fixed | Open | Tier 1 (HIGH) | Tier 2 (MEDIUM) | Tier 3 (LOW) |
133
+ |----------|-------|-------|------|--------------|-----------------|--------------|
134
+ | Open Bugs | 7 | 6 | 1 | 0 | 0 | 1 (B2) |
135
+ | analyze_feature | 14 | 0 | 14 | 5 | 5 | 4 |
136
+ | design_system | 21 | 0 | 21 | 9 | 6 | 6 |
137
+ | Rules serializer | 4 | 3 | 1 | 1 (RS1 partial) | 0 | 0 |
138
+ | validate | 2 | 0 | 2 | 0 | 0 | 2 |
139
+ | model_details | 1 | 0 | 1 | 0 | 0 | 1 |
140
+ | **Total** | **49** | **9** | **40** | **15** | **11** | **14** |
141
+
142
+ ### Killer differentiators (no other tool does these)
143
+
144
+ 1. **Execution flow graph** (AF7) — trace a full request from HTTP to database to broadcast in one call
145
+ 2. **"When to use what" decision guide** (DS8) — not just patterns but rules for choosing the right one
146
+ 3. **Auto-detect CSS framework** (DS13-DS15) — works for Tailwind, Bootstrap, custom CSS, any Rails app
147
+ 4. **Services + Jobs + Views + Tests in feature analysis** (AF1-AF5) — full-stack feature discovery in one call
148
+ 5. **Environment dependency detection** (AF9) — know what needs to be running before you touch a feature
@@ -171,6 +171,10 @@ module RailsAiContext
171
171
  f[:if] = sc[:if] if sc[:if]
172
172
  end
173
173
  end
174
+
175
+ # Evaluate known runtime conditions to remove inapplicable filters
176
+ reflection_filters.reject! { |f| filter_excluded_by_condition?(ctrl, f) }
177
+
174
178
  return reflection_filters
175
179
  end
176
180
  end
@@ -258,6 +262,29 @@ module RailsAiContext
258
262
  nil
259
263
  end
260
264
 
265
+ # Statically evaluate known runtime conditions to exclude inapplicable filters.
266
+ # e.g., `unless: :devise_controller?` on a Devise controller means the filter doesn't apply.
267
+ def filter_excluded_by_condition?(ctrl, filter)
268
+ # unless: :devise_controller? — filter does NOT apply to Devise controllers
269
+ if filter[:unless] == "devise_controller?"
270
+ return true if devise_controller?(ctrl)
271
+ end
272
+
273
+ # if: :devise_controller? — filter ONLY applies to Devise controllers
274
+ if filter[:if] == "devise_controller?"
275
+ return true unless devise_controller?(ctrl)
276
+ end
277
+
278
+ false
279
+ end
280
+
281
+ def devise_controller?(ctrl)
282
+ return false unless defined?(::DeviseController)
283
+ ctrl < ::DeviseController || ctrl.ancestors.any? { |a| a.name&.start_with?("Devise::") }
284
+ rescue
285
+ false
286
+ end
287
+
261
288
  def extract_action_condition(condition)
262
289
  return nil unless condition.is_a?(String) || condition.respond_to?(:to_s)
263
290
  match = condition.to_s.match(/action_name\s*==\s*['"](\w+)['"]/)
@@ -32,6 +32,7 @@ module RailsAiContext
32
32
  extract_css_custom_properties(root, tokens)
33
33
  extract_webpacker_styles(root, tokens)
34
34
  extract_component_css(root, tokens)
35
+ extract_apply_directives(root, tokens)
35
36
 
36
37
  return { skipped: true, reason: "No design tokens found" } if tokens.empty?
37
38
 
@@ -48,20 +49,29 @@ module RailsAiContext
48
49
 
49
50
  def detect_framework(root)
50
51
  gemfile = File.join(root, "Gemfile")
51
- return "unknown" unless File.exist?(gemfile)
52
- content = File.read(gemfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
52
+ package_json = File.join(root, "package.json")
53
+ gemfile_content = File.exist?(gemfile) ? (File.read(gemfile, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue "") : ""
54
+ pkg_content = File.exist?(package_json) ? (File.read(package_json, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue "") : ""
53
55
 
54
- if content.include?("tailwindcss-rails")
56
+ framework = if gemfile_content.include?("tailwindcss-rails")
55
57
  "tailwind"
56
- elsif content.include?("bootstrap")
58
+ elsif gemfile_content.include?("bootstrap")
57
59
  "bootstrap"
58
- elsif content.include?("dartsass-rails") || content.include?("sassc-rails") || content.include?("sass-rails")
60
+ elsif gemfile_content.include?("dartsass-rails") || gemfile_content.include?("sassc-rails") || gemfile_content.include?("sass-rails")
59
61
  "sass"
60
- elsif content.include?("cssbundling-rails")
62
+ elsif gemfile_content.include?("cssbundling-rails")
61
63
  "cssbundling"
62
64
  else
63
65
  "plain_css"
64
66
  end
67
+
68
+ # DS17: Detect Tailwind plugin libraries
69
+ plugins = []
70
+ plugins << "daisyui" if pkg_content.include?("daisyui") || gemfile_content.include?("daisyui")
71
+ plugins << "flowbite" if pkg_content.include?("flowbite")
72
+ plugins << "headlessui" if pkg_content.include?("headlessui") || pkg_content.include?("@headlessui")
73
+
74
+ plugins.any? ? "#{framework}+#{plugins.join('+')}" : framework
65
75
  rescue
66
76
  "unknown"
67
77
  end
@@ -238,6 +248,18 @@ module RailsAiContext
238
248
  categories.reject { |_, v| v.empty? }
239
249
  end
240
250
 
251
+ # DS16: Extract @apply directives as named component classes
252
+ def extract_apply_directives(root, tokens)
253
+ %w[app/assets/stylesheets app/assets/tailwind].each do |dir|
254
+ Dir.glob(File.join(root, dir, "**", "*.css")).each do |path|
255
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
256
+ content.scan(/\.([a-zA-Z][\w-]*)\s*\{[^}]*@apply\s+([^;]+);/m).each do |name, classes|
257
+ tokens["@apply-#{name}"] = classes.strip
258
+ end
259
+ end
260
+ end
261
+ end
262
+
241
263
  # Helper: extract :root { --var: value } from CSS content
242
264
  def extract_root_vars(content, tokens)
243
265
  content.scan(/:root\s*(?:,\s*:host)?\s*\{([^}]+)\}/m).each do |match|
@@ -239,7 +239,8 @@ module RailsAiContext
239
239
  all_methods = (model.methods - ActiveRecord::Base.methods - Object.methods)
240
240
  .reject { |m|
241
241
  ms = m.to_s
242
- ms.start_with?("_", "autosave") ||
242
+ ms == "self" ||
243
+ ms.start_with?("_", "autosave") ||
243
244
  scope_names.include?(ms) ||
244
245
  DEVISE_CLASS_METHOD_PATTERNS.include?(ms) ||
245
246
  ms.end_with?("=") && ms.length > 20 # Devise setter-like methods
@@ -21,7 +21,8 @@ module RailsAiContext
21
21
  templates: scan_templates(views_dir),
22
22
  partials: scan_partials(views_dir),
23
23
  ui_patterns: extract_ui_patterns(all_content).merge(
24
- canonical_examples: extract_canonical_examples(views_dir)
24
+ canonical_examples: extract_canonical_examples(views_dir),
25
+ shared_partials: discover_shared_partials(views_dir)
25
26
  )
26
27
  }
27
28
  rescue => e
@@ -86,7 +87,10 @@ module RailsAiContext
86
87
  components = []
87
88
  used = Set.new
88
89
 
89
- # Extract element-aware patterns
90
+ # DS13-15: Framework-aware component extraction
91
+ extract_bootstrap_components(all_content, components, used) if all_content.match?(/btn-|form-control|card-body/)
92
+
93
+ # Extract element-aware patterns (Tailwind + generic)
90
94
  extract_buttons(all_content, class_groups, components, used)
91
95
  extract_cards(class_groups, components, used)
92
96
  extract_inputs(all_content, class_groups, components, used)
@@ -98,6 +102,8 @@ module RailsAiContext
98
102
  extract_headings(all_content, components)
99
103
  extract_flashes(all_content, components)
100
104
  extract_alerts(class_groups, components, used)
105
+ extract_modals(all_content, class_groups, components, used)
106
+ extract_list_items(class_groups, components, used)
101
107
 
102
108
  # Design tokens
103
109
  color_scheme = extract_color_scheme(all_content, class_groups)
@@ -114,7 +120,8 @@ module RailsAiContext
114
120
  responsive: extract_responsive_patterns(all_content),
115
121
  interactive_states: extract_interactive_states(all_content),
116
122
  dark_mode: extract_dark_mode_patterns(all_content),
117
- icons: extract_icon_system(all_content)
123
+ icons: extract_icon_system(all_content),
124
+ animations: extract_animations(all_content)
118
125
  }
119
126
  end
120
127
 
@@ -302,6 +309,71 @@ module RailsAiContext
302
309
  components << { type: :alert, label: "Alert", classes: best[0] }
303
310
  end
304
311
 
312
+ # DS13-15: Bootstrap component extraction
313
+ def extract_bootstrap_components(content, components, used)
314
+ bootstrap_patterns = {
315
+ "btn-primary" => { type: :button, label: "Button (primary)" },
316
+ "btn-secondary" => { type: :button, label: "Button (secondary)" },
317
+ "btn-danger" => { type: :button, label: "Button (danger)" },
318
+ "btn-outline-primary" => { type: :button, label: "Button (outline)" },
319
+ "card" => { type: :card, label: "Card" },
320
+ "modal" => { type: :modal_card, label: "Modal" },
321
+ "form-control" => { type: :input, label: "Input" },
322
+ "form-select" => { type: :select, label: "Select" },
323
+ "badge" => { type: :badge, label: "Badge" },
324
+ "alert" => { type: :alert, label: "Alert" },
325
+ "nav" => { type: :nav, label: "Navigation" }
326
+ }
327
+
328
+ bootstrap_patterns.each do |pattern, meta|
329
+ matches = content.scan(/class=["'][^"']*\b#{pattern}\b[^"']*["']/).map do |m|
330
+ m.gsub(/class=["']|["']/, "").strip
331
+ end
332
+ next if matches.empty?
333
+
334
+ best = matches.tally.max_by { |_, c| c }
335
+ unless used.include?(best[0])
336
+ components << meta.merge(classes: best[0])
337
+ used << best[0]
338
+ end
339
+ end
340
+ end
341
+
342
+ # DS1: Modal/overlay patterns
343
+ def extract_modals(content, groups, components, used)
344
+ # Detect overlay pattern (fixed inset-0 bg-black/50 or similar)
345
+ overlay = groups.select { |c, _| c.match?(/fixed.*inset-0|fixed.*z-\d+.*bg-/) && !used.include?(c) }
346
+ if overlay.any?
347
+ best = overlay.max_by { |_, count| count }
348
+ components << { type: :modal_overlay, label: "Modal overlay", classes: best[0] }
349
+ used << best[0]
350
+ end
351
+
352
+ # Detect modal card (usually the child of overlay)
353
+ modal_card = groups.select do |c, _|
354
+ c.match?(/bg-white.*rounded.*shadow.*max-w-|modal/) && c.match?(/p-\d/) && !used.include?(c)
355
+ end
356
+ if modal_card.any?
357
+ best = modal_card.max_by { |_, count| count }
358
+ components << { type: :modal_card, label: "Modal card", classes: best[0] }
359
+ used << best[0]
360
+ end
361
+ end
362
+
363
+ # DS5: List item / repeating card pattern
364
+ def extract_list_items(groups, components, used)
365
+ candidates = groups.select do |c, count|
366
+ count >= 3 && # appears 3+ times (repeating pattern)
367
+ c.match?(/(?:flex|grid).*(?:items-|justify-|gap-)/) &&
368
+ c.match?(/(?:bg-white|border|shadow|rounded)/) &&
369
+ !used.include?(c)
370
+ end
371
+ return if candidates.empty?
372
+ best = candidates.max_by { |_, count| count }
373
+ components << { type: :list_item, label: "List item", classes: best[0] }
374
+ used << best[0]
375
+ end
376
+
305
377
  def extract_color_scheme(content, groups)
306
378
  # Find primary color from button backgrounds
307
379
  primary_colors = Hash.new(0)
@@ -452,6 +524,29 @@ module RailsAiContext
452
524
  icons.empty? ? nil : icons
453
525
  end
454
526
 
527
+ # DS19: Animation and transition patterns
528
+ def extract_animations(content)
529
+ animations = {}
530
+
531
+ # Transition classes
532
+ transitions = content.scan(/transition(?:-\w+)*/).tally
533
+ animations[:transitions] = transitions.sort_by { |_, c| -c }.first(5).to_h unless transitions.empty?
534
+
535
+ # Duration classes
536
+ durations = content.scan(/duration-\d+/).tally
537
+ animations[:durations] = durations.sort_by { |_, c| -c }.first(3).to_h unless durations.empty?
538
+
539
+ # Animate classes
540
+ animates = content.scan(/animate-\w+/).tally
541
+ animations[:animates] = animates.sort_by { |_, c| -c }.first(5).to_h unless animates.empty?
542
+
543
+ # Ease classes
544
+ eases = content.scan(/ease-\w+/).tally
545
+ animations[:easing] = eases.sort_by { |_, c| -c }.first(3).to_h unless eases.empty?
546
+
547
+ animations.empty? ? nil : animations
548
+ end
549
+
455
550
  # Analyzes individual templates to find canonical examples of common page types.
456
551
  # Returns up to 5 representative ERB snippets that AI can copy.
457
552
  def extract_canonical_examples(views_dir) # rubocop:disable Metrics
@@ -541,6 +636,36 @@ module RailsAiContext
541
636
  used
542
637
  end
543
638
 
639
+ # DS7: Shared partials with one-line descriptions
640
+ def discover_shared_partials(views_dir)
641
+ shared_dir = File.join(views_dir, "shared")
642
+ return [] unless Dir.exist?(shared_dir)
643
+
644
+ Dir.glob(File.join(shared_dir, "_*.{erb,haml,slim}")).sort.map do |path|
645
+ name = File.basename(path)
646
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue ""
647
+ description = infer_partial_description(name, content)
648
+ { name: name, lines: content.lines.size, description: description }
649
+ end
650
+ rescue
651
+ []
652
+ end
653
+
654
+ def infer_partial_description(name, content)
655
+ # Infer from content patterns
656
+ return "Flash/notification messages" if name.include?("flash") || name.include?("notification")
657
+ return "Navigation bar" if name.include?("nav") || name.include?("header")
658
+ return "Footer" if name.include?("footer")
659
+ return "Status badge/indicator" if name.include?("status") || name.include?("badge")
660
+ return "Loading/spinner" if name.include?("loading") || name.include?("spinner") || content.include?("animate-spin")
661
+ return "Modal dialog" if name.include?("modal") || name.include?("dialog")
662
+ return "Form component" if name.include?("form") || content.match?(/form_with|form_for/)
663
+ return "Upgrade prompt" if name.include?("upgrade") || name.include?("nudge")
664
+ return "Share dialog" if name.include?("share")
665
+ return "Error display" if name.include?("error")
666
+ "Shared partial (#{content.lines.size} lines)"
667
+ end
668
+
544
669
  EXCLUDED_METHODS = %w[
545
670
  each map select reject first last size count any? empty? present? blank?
546
671
  new build create find where order limit nil? join class html_safe
@@ -218,30 +218,8 @@ module RailsAiContext
218
218
  scopes = data[:scopes] || []
219
219
  lines << " scopes: #{scopes.join(', ')}" if scopes.any?
220
220
 
221
- # Include app-specific instance methods (filter out Rails/Devise-generated ones)
222
- generated_patterns = %w[build_ create_ reload_ reset_ _changed? _previously_changed?
223
- _ids _ids= _before_last_save _before_type_cast _came_from_user?
224
- _for_database _in_database _was]
225
- # Filter out association getters (cooks, user, plan, etc.)
226
- assoc_names = (data[:associations] || []).flat_map { |a| [ a[:name].to_s, "#{a[:name]}=" ] }
227
- # Filter out Devise and other framework-generated methods
228
- devise_methods = %w[active_for_authentication? after_database_authentication after_remembered
229
- authenticatable_salt inactive_message confirmation_required?
230
- send_confirmation_instructions password_required? email_required?
231
- will_save_change_to_email? clean_up_passwords current_password
232
- destroy_with_password devise_mailer devise_modules devise_scope
233
- send_devise_notification valid_password? update_with_password
234
- send_reset_password_instructions apply_to_attribute_or_variable
235
- allowed_gemini_models remember_me! forget_me!
236
- skip_confirmation! skip_reconfirmation!]
237
- devise_patterns = %w[devise_ _password _authenticatable _confirmation _recoverable]
238
- methods = (data[:instance_methods] || []).reject { |m|
239
- generated_patterns.any? { |p| m.include?(p) } ||
240
- m.end_with?("=") ||
241
- assoc_names.include?(m) ||
242
- devise_methods.include?(m) ||
243
- devise_patterns.any? { |p| m.include?(p) }
244
- }.first(10)
221
+ # Instance methods introspector already prioritizes source-defined and filters Devise
222
+ methods = (data[:instance_methods] || []).reject { |m| m.end_with?("=") }.first(20)
245
223
  lines << " methods: #{methods.join(', ')}" if methods.any?
246
224
 
247
225
  # Include constants (e.g. STATUSES, MODES) so agents know valid values
@@ -32,6 +32,7 @@ module RailsAiContext
32
32
  lines.concat(render_spacing_summary(patterns))
33
33
  lines.concat(render_interaction_summary(patterns))
34
34
  lines.concat(render_dark_mode_summary(patterns))
35
+ lines.concat(render_decision_guide(patterns))
35
36
  lines.concat(render_design_rules(patterns))
36
37
 
37
38
  lines.first(max_lines)
@@ -83,6 +84,17 @@ module RailsAiContext
83
84
  lines << ""
84
85
  end
85
86
 
87
+ # Shared partials with descriptions
88
+ shared = patterns[:shared_partials] || []
89
+ if shared.any?
90
+ lines << "## Shared Partials — Reuse Before Creating New Markup"
91
+ lines << ""
92
+ shared.each do |p|
93
+ lines << "- `#{p[:name]}` — #{p[:description]}"
94
+ end
95
+ lines << ""
96
+ end
97
+
86
98
  lines
87
99
  end
88
100
 
@@ -201,6 +213,41 @@ module RailsAiContext
201
213
  lines
202
214
  end
203
215
 
216
+ # DS8: Decision guide — when to use what
217
+ def render_decision_guide(patterns)
218
+ components = patterns[:components] || []
219
+ return [] if components.size < 3
220
+
221
+ lines = [ "### When to Use What" ]
222
+
223
+ # Button decisions
224
+ has_primary = components.any? { |c| c[:label]&.include?("primary") }
225
+ has_danger = components.any? { |c| c[:label]&.include?("danger") }
226
+ has_secondary = components.any? { |c| c[:label]&.include?("secondary") }
227
+ if has_primary || has_danger
228
+ lines << "- **Primary action** (Save, Submit, Continue) → Primary button"
229
+ lines << "- **Secondary action** (Cancel, Back, Skip) → Secondary button" if has_secondary
230
+ lines << "- **Destructive action** (Delete, Remove) → Danger button" if has_danger
231
+ end
232
+
233
+ # Turbo confirm
234
+ lines << "- **Confirmation needed** → `data: { turbo_confirm: \"Are you sure?\" }` on `button_to`"
235
+
236
+ # Shared partials usage
237
+ shared = patterns[:shared_partials] || []
238
+ shared.each do |p|
239
+ case p[:name]
240
+ when /flash|notification/ then lines << "- **Show feedback** → `render \"shared/#{p[:name].sub(/\A_/, '').sub(/\..*/, '')}\"` "
241
+ when /status|badge/ then lines << "- **Show status** → `render \"shared/#{p[:name].sub(/\A_/, '').sub(/\..*/, '')}\"` "
242
+ when /modal|dialog/ then lines << "- **Need overlay/dialog** → `render \"shared/#{p[:name].sub(/\A_/, '').sub(/\..*/, '')}\"` "
243
+ when /loading|spinner/ then lines << "- **Show loading** → `render \"shared/#{p[:name].sub(/\A_/, '').sub(/\..*/, '')}\"` "
244
+ end
245
+ end
246
+
247
+ lines << ""
248
+ lines
249
+ end
250
+
204
251
  def render_design_rules(patterns)
205
252
  lines = [ "### Design Rules" ]
206
253
 
@@ -4,7 +4,8 @@ module RailsAiContext
4
4
  module Tools
5
5
  class AnalyzeFeature < BaseTool
6
6
  tool_name "rails_analyze_feature"
7
- description "Analyze a feature end-to-end: finds matching models, controllers, routes, and views in one call. " \
7
+ description "Full-stack feature analysis: models, controllers, routes, services, jobs, views, " \
8
+ "Stimulus controllers, tests, related models, callbacks, concerns, and environment dependencies. " \
8
9
  "Use when: exploring an unfamiliar feature, onboarding to a codebase area, or tracing a feature across layers. " \
9
10
  "Pass feature:\"authentication\" or feature:\"User\" for broad cross-cutting discovery."
10
11
 
@@ -12,7 +13,7 @@ module RailsAiContext
12
13
  properties: {
13
14
  feature: {
14
15
  type: "string",
15
- description: "Feature keyword to search for (e.g. 'authentication', 'User', 'payments', 'orders'). Case-insensitive partial match across models, controllers, and routes."
16
+ description: "Feature keyword to search for (e.g. 'authentication', 'User', 'payments', 'orders'). Case-insensitive partial match across all layers."
16
17
  }
17
18
  },
18
19
  required: [ "feature" ]
@@ -20,95 +21,362 @@ module RailsAiContext
20
21
 
21
22
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
22
23
 
23
- def self.call(feature:, server_context: nil)
24
+ def self.call(feature:, server_context: nil) # rubocop:disable Metrics
24
25
  ctx = cached_context
25
26
  pattern = feature.downcase
27
+ root = rails_app.root.to_s
26
28
  lines = [ "# Feature Analysis: #{feature}", "" ]
27
29
 
28
- # --- Models ---
29
- models = ctx[:models] || {}
30
- matched_models = models.select do |name, data|
31
- next false if data[:error]
32
- # Match on model name, table name, or underscore form
33
- name.downcase.include?(pattern) ||
34
- data[:table_name]&.downcase&.include?(pattern) ||
35
- name.underscore.include?(pattern)
36
- end
30
+ matched_models = discover_models(ctx, pattern, lines)
31
+ discover_controllers(ctx, pattern, lines)
32
+ discover_routes(ctx, pattern, lines)
33
+ discover_services(root, pattern, lines)
34
+ discover_jobs(root, pattern, lines)
35
+ discover_views(ctx, root, pattern, lines)
36
+ discover_stimulus(ctx, pattern, lines)
37
+ discover_tests(root, pattern, lines)
38
+ discover_related_models(ctx, matched_models, lines)
39
+ discover_concerns(ctx, matched_models, lines)
40
+ discover_callbacks(ctx, matched_models, lines)
41
+ discover_channels(root, pattern, lines)
42
+ discover_mailers(root, pattern, lines)
43
+ discover_env_dependencies(root, pattern, matched_models, lines)
37
44
 
38
- if matched_models.any?
39
- lines << "## Models (#{matched_models.size} matched)"
40
- matched_models.sort.each do |name, data|
41
- lines << ""
42
- lines << "### #{name}"
43
- lines << "**Table:** `#{data[:table_name]}`" if data[:table_name]
44
-
45
- # Schema columns from schema introspection
46
- table_name = data[:table_name]
47
- if table_name && (schema = ctx[:schema]) && (tables = schema[:tables])
48
- table_data = tables[table_name]
49
- if table_data && table_data[:columns]&.any?
50
- cols = table_data[:columns].reject { |c| %w[id created_at updated_at].include?(c[:name]) }
51
- lines << "**Columns:** #{cols.map { |c| "#{c[:name]}:#{c[:type]}" }.join(', ')}" if cols.any?
52
- if table_data[:indexes]&.any?
53
- lines << "**Indexes:** #{table_data[:indexes].map { |i| "#{i[:columns].join(',')}#{i[:unique] ? ' (unique)' : ''}" }.join('; ')}"
54
- end
55
- if table_data[:foreign_keys]&.any?
56
- lines << "**FKs:** #{table_data[:foreign_keys].map { |fk| "#{fk[:column]} -> #{fk[:to_table]}" }.join(', ')}"
45
+ text_response(lines.join("\n"))
46
+ end
47
+
48
+ class << self
49
+ private
50
+
51
+ # --- AF: Models ---
52
+ def discover_models(ctx, pattern, lines)
53
+ models = ctx[:models] || {}
54
+ matched = models.select do |name, data|
55
+ next false if data[:error]
56
+ name.downcase.include?(pattern) ||
57
+ data[:table_name]&.downcase&.include?(pattern) ||
58
+ name.underscore.include?(pattern)
59
+ end
60
+
61
+ if matched.any?
62
+ lines << "## Models (#{matched.size})"
63
+ matched.sort.each do |name, data|
64
+ lines << "" << "### #{name}"
65
+ lines << "**Table:** `#{data[:table_name]}`" if data[:table_name]
66
+
67
+ table_name = data[:table_name]
68
+ if table_name && (tables = ctx.dig(:schema, :tables))
69
+ table_data = tables[table_name]
70
+ if table_data&.dig(:columns)&.any?
71
+ cols = table_data[:columns].reject { |c| %w[id created_at updated_at].include?(c[:name]) }
72
+ col_strs = cols.map { |c| col_type = c[:array] ? "#{c[:type]}[]" : c[:type]; "#{c[:name]}:#{col_type}" }
73
+ lines << "**Columns:** #{col_strs.join(', ')}" if cols.any?
57
74
  end
58
75
  end
59
- end
60
76
 
61
- if data[:associations]&.any?
62
- lines << "**Associations:** #{data[:associations].map { |a| "#{a[:type]} :#{a[:name]}" }.join(', ')}"
77
+ lines << "**Associations:** #{data[:associations].map { |a| "#{a[:type]} :#{a[:name]}" }.join(', ')}" if data[:associations]&.any?
78
+ lines << "**Validations:** #{data[:validations].map { |v| "#{v[:kind]} on #{v[:attributes].join(', ')}" }.uniq.join('; ')}" if data[:validations]&.any?
79
+ lines << "**Scopes:** #{data[:scopes].join(', ')}" if data[:scopes]&.any?
80
+ lines << "**Enums:** #{data[:enums].map { |k, v| "#{k}: #{v.join(', ')}" }.join('; ')}" if data[:enums]&.any?
63
81
  end
64
- if data[:validations]&.any?
65
- lines << "**Validations:** #{data[:validations].map { |v| "#{v[:kind]} on #{v[:attributes].join(', ')}" }.uniq.join('; ')}"
82
+ else
83
+ lines << "## Models" << "_No models matching '#{pattern}'._"
84
+ end
85
+
86
+ lines << ""
87
+ matched
88
+ end
89
+
90
+ # --- AF: Controllers ---
91
+ def discover_controllers(ctx, pattern, lines)
92
+ controllers = ctx.dig(:controllers, :controllers) || {}
93
+ matched = controllers.select { |name, data| !data[:error] && name.downcase.include?(pattern) }
94
+
95
+ if matched.any?
96
+ lines << "## Controllers (#{matched.size})"
97
+ matched.sort.each do |name, info|
98
+ actions = info[:actions]&.join(", ") || "none"
99
+ lines << "" << "### #{name}"
100
+ lines << "- **Actions:** #{actions}"
101
+ filters = (info[:filters] || []).map do |f|
102
+ label = "#{f[:kind]} #{f[:name]}"
103
+ label += " only: #{f[:only].join(', ')}" if f[:only]&.any?
104
+ label += " except: #{f[:except].join(', ')}" if f[:except]&.any?
105
+ label += " unless: #{f[:unless]}" if f[:unless]
106
+ label
107
+ end
108
+ lines << "- **Filters:** #{filters.join('; ')}" if filters.any?
66
109
  end
67
- if data[:scopes]&.any?
68
- lines << "**Scopes:** #{data[:scopes].join(', ')}"
110
+ else
111
+ lines << "## Controllers" << "_No controllers matching '#{pattern}'._"
112
+ end
113
+ lines << ""
114
+ end
115
+
116
+ # --- AF: Routes ---
117
+ def discover_routes(ctx, pattern, lines)
118
+ by_controller = ctx.dig(:routes, :by_controller) || {}
119
+ matched = by_controller.select { |ctrl, _| ctrl.downcase.include?(pattern) }
120
+
121
+ if matched.any?
122
+ route_count = matched.values.sum(&:size)
123
+ lines << "## Routes (#{route_count})"
124
+ matched.sort.each do |ctrl, actions|
125
+ actions.each do |r|
126
+ name_part = r[:name] ? " `#{r[:name]}`" : ""
127
+ lines << "- `#{r[:verb]}` `#{r[:path]}` → #{ctrl}##{r[:action]}#{name_part}"
128
+ end
69
129
  end
130
+ else
131
+ lines << "## Routes" << "_No routes matching '#{pattern}'._"
132
+ end
133
+ lines << ""
134
+ end
135
+
136
+ # --- AF1: Services ---
137
+ def discover_services(root, pattern, lines)
138
+ dir = File.join(root, "app", "services")
139
+ return unless Dir.exist?(dir)
140
+
141
+ found = Dir.glob(File.join(dir, "**", "*.rb")).select do |path|
142
+ File.basename(path, ".rb").include?(pattern) ||
143
+ (File.size(path) < 50_000 && File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace).downcase.include?(pattern))
144
+ end
145
+ return if found.empty?
146
+
147
+ lines << "## Services (#{found.size})"
148
+ found.each do |path|
149
+ relative = path.sub("#{root}/", "")
150
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
151
+ line_count = source.lines.size
152
+ methods = source.scan(/\A\s*def (?:self\.)?(\w+)/m).flatten.reject { |m| m == "initialize" }
153
+ lines << "- `#{relative}` (#{line_count} lines)"
154
+ lines << " Methods: #{methods.first(10).join(', ')}" if methods.any?
155
+ end
156
+ lines << ""
157
+ rescue
158
+ nil
159
+ end
160
+
161
+ # --- AF2: Jobs ---
162
+ def discover_jobs(root, pattern, lines)
163
+ dir = File.join(root, "app", "jobs")
164
+ return unless Dir.exist?(dir)
165
+
166
+ found = Dir.glob(File.join(dir, "**", "*.rb")).select do |path|
167
+ File.basename(path, ".rb").include?(pattern)
168
+ end
169
+ return if found.empty?
170
+
171
+ lines << "## Jobs (#{found.size})"
172
+ found.each do |path|
173
+ relative = path.sub("#{root}/", "")
174
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
175
+ queue = source.match(/queue_as\s+[:'"](\w+)/)&.captures&.first || "default"
176
+ retries = source.match(/retry_on.*attempts:\s*(\d+)/)&.captures&.first
177
+ lines << "- `#{relative}` (queue: #{queue}#{retries ? ", retries: #{retries}" : ""})"
178
+ end
179
+ lines << ""
180
+ rescue
181
+ nil
182
+ end
183
+
184
+ # --- AF3: Views + Partials ---
185
+ def discover_views(ctx, root, pattern, lines)
186
+ views_dir = File.join(root, "app", "views")
187
+ return unless Dir.exist?(views_dir)
188
+
189
+ found = Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).select do |path|
190
+ path.sub("#{views_dir}/", "").downcase.include?(pattern)
70
191
  end
71
- else
72
- lines << "## Models" << "_No models matching '#{feature}'._"
192
+ return if found.empty?
193
+
194
+ lines << "## Views (#{found.size})"
195
+ found.each do |path|
196
+ relative = path.sub("#{views_dir}/", "")
197
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
198
+ line_count = source.lines.size
199
+ partials = source.scan(/render\s+(?:partial:\s*)?["']([^"']+)["']/).flatten
200
+ stimulus = source.scan(/data-controller=["']([^"']+)["']/).flat_map { |m| m.first.split }
201
+ detail = "- `#{relative}` (#{line_count} lines)"
202
+ detail += " renders: #{partials.join(', ')}" if partials.any?
203
+ detail += " stimulus: #{stimulus.join(', ')}" if stimulus.any?
204
+ lines << detail
205
+ end
206
+ lines << ""
207
+ rescue
208
+ nil
73
209
  end
74
210
 
75
- # --- Controllers ---
76
- controllers = (ctx.dig(:controllers, :controllers) || {})
77
- matched_controllers = controllers.select { |name, data| !data[:error] && name.downcase.include?(pattern) }
211
+ # --- AF4: Stimulus Controllers ---
212
+ def discover_stimulus(ctx, pattern, lines)
213
+ stim = ctx[:stimulus]
214
+ return unless stim.is_a?(Hash) && !stim[:error]
215
+
216
+ controllers = stim[:controllers] || []
217
+ matched = controllers.select do |c|
218
+ name = c[:name] || c[:file]&.gsub("_controller.js", "")
219
+ name&.downcase&.include?(pattern)
220
+ end
221
+ return if matched.empty?
78
222
 
79
- lines << ""
80
- if matched_controllers.any?
81
- lines << "## Controllers (#{matched_controllers.size} matched)"
82
- matched_controllers.sort.each do |name, info|
83
- actions = info[:actions]&.join(", ") || "none"
84
- filters = (info[:filters] || []).map { |f| "#{f[:kind]} #{f[:name]}" }.join(", ")
223
+ lines << "## Stimulus Controllers (#{matched.size})"
224
+ matched.each do |c|
225
+ name = c[:name] || c[:file]&.gsub("_controller.js", "")
85
226
  lines << "" << "### #{name}"
86
- lines << "- **Actions:** #{actions}"
87
- lines << "- **Filters:** #{filters}" unless filters.empty?
227
+ lines << "- **Targets:** #{c[:targets].join(', ')}" if c[:targets]&.any?
228
+ lines << "- **Values:** #{c[:values].map { |v| "#{v[:name]}:#{v[:type]}" }.join(', ')}" if c[:values]&.any?
229
+ lines << "- **Actions:** #{c[:actions].join(', ')}" if c[:actions]&.any?
88
230
  end
89
- else
90
- lines << "## Controllers" << "_No controllers matching '#{feature}'._"
231
+ lines << ""
91
232
  end
92
233
 
93
- # --- Routes ---
94
- by_controller = (ctx.dig(:routes, :by_controller) || {})
95
- matched_routes = by_controller.select { |ctrl, _| ctrl.downcase.include?(pattern) }
96
-
97
- lines << ""
98
- if matched_routes.any?
99
- route_count = matched_routes.values.sum(&:size)
100
- lines << "## Routes (#{route_count} matched)"
101
- matched_routes.sort.each do |ctrl, actions|
102
- actions.each do |r|
103
- name_part = r[:name] ? " `#{r[:name]}`" : ""
104
- lines << "- `#{r[:verb]}` `#{r[:path]}` -> #{ctrl}##{r[:action]}#{name_part}"
234
+ # --- AF5: Tests ---
235
+ def discover_tests(root, pattern, lines)
236
+ test_dirs = [ File.join(root, "spec"), File.join(root, "test") ]
237
+ found = []
238
+
239
+ test_dirs.each do |dir|
240
+ next unless Dir.exist?(dir)
241
+ Dir.glob(File.join(dir, "**", "*_{test,spec}.rb")).each do |path|
242
+ found << path if File.basename(path, ".rb").include?(pattern)
243
+ end
244
+ Dir.glob(File.join(dir, "**", "{test,spec}_*.rb")).each do |path|
245
+ found << path if File.basename(path, ".rb").include?(pattern)
105
246
  end
106
247
  end
107
- else
108
- lines << "## Routes" << "_No routes matching '#{feature}'._"
248
+ found.uniq!
249
+ return if found.empty?
250
+
251
+ lines << "## Tests (#{found.size})"
252
+ found.each do |path|
253
+ relative = path.sub("#{root}/", "")
254
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
255
+ test_count = source.scan(/\b(?:it|test|should)\b/).size
256
+ lines << "- `#{relative}` (#{test_count} tests)"
257
+ end
258
+ lines << ""
259
+ rescue
260
+ nil
109
261
  end
110
262
 
111
- text_response(lines.join("\n"))
263
+ # --- AF6: Related Models via Associations ---
264
+ def discover_related_models(ctx, matched_models, lines)
265
+ return if matched_models.empty?
266
+
267
+ related = {}
268
+ matched_models.each do |name, data|
269
+ (data[:associations] || []).each do |a|
270
+ related_name = a[:class_name] || a[:name].to_s.classify
271
+ next if matched_models.key?(related_name)
272
+ related[related_name] ||= []
273
+ related[related_name] << "#{a[:type]} from #{name}"
274
+ end
275
+ end
276
+ return if related.empty?
277
+
278
+ lines << "## Related Models (#{related.size})"
279
+ related.sort.each { |name, refs| lines << "- **#{name}** — #{refs.join(', ')}" }
280
+ lines << ""
281
+ end
282
+
283
+ # --- AF12: Concern Tracing ---
284
+ def discover_concerns(ctx, matched_models, lines)
285
+ return if matched_models.empty?
286
+
287
+ concerns = {}
288
+ matched_models.each do |_name, data|
289
+ (data[:concerns] || []).each do |c|
290
+ next if c.include?("::") || %w[Kernel JSON PP].include?(c)
291
+ concerns[c] ||= 0
292
+ concerns[c] += 1
293
+ end
294
+ end
295
+ return if concerns.empty?
296
+
297
+ lines << "## Concerns"
298
+ concerns.sort.each { |name, count| lines << "- **#{name}** (used by #{count} model#{'s' if count > 1})" }
299
+ lines << ""
300
+ end
301
+
302
+ # --- AF13: Callback Chains ---
303
+ def discover_callbacks(ctx, matched_models, lines)
304
+ return if matched_models.empty?
305
+
306
+ callbacks = []
307
+ matched_models.each do |name, data|
308
+ (data[:callbacks] || {}).each do |type, methods|
309
+ methods.each { |m| callbacks << "#{name}: #{type} :#{m}" }
310
+ end
311
+ end
312
+ return if callbacks.empty?
313
+
314
+ lines << "## Callbacks"
315
+ callbacks.each { |c| lines << "- #{c}" }
316
+ lines << ""
317
+ end
318
+
319
+ # --- AF10: Channels/WebSocket ---
320
+ def discover_channels(root, pattern, lines)
321
+ dir = File.join(root, "app", "channels")
322
+ return unless Dir.exist?(dir)
323
+
324
+ found = Dir.glob(File.join(dir, "**", "*.rb")).select { |p| File.basename(p, ".rb").include?(pattern) }
325
+ return if found.empty?
326
+
327
+ lines << "## Channels (#{found.size})"
328
+ found.each do |path|
329
+ relative = path.sub("#{root}/", "")
330
+ lines << "- `#{relative}`"
331
+ end
332
+ lines << ""
333
+ rescue
334
+ nil
335
+ end
336
+
337
+ # --- AF11: Mailers ---
338
+ def discover_mailers(root, pattern, lines)
339
+ dir = File.join(root, "app", "mailers")
340
+ return unless Dir.exist?(dir)
341
+
342
+ found = Dir.glob(File.join(dir, "**", "*.rb")).select { |p| File.basename(p, ".rb").include?(pattern) }
343
+ return if found.empty?
344
+
345
+ lines << "## Mailers (#{found.size})"
346
+ found.each do |path|
347
+ relative = path.sub("#{root}/", "")
348
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
349
+ methods = source.scan(/\A\s*def (\w+)/m).flatten.reject { |m| m == "initialize" }
350
+ lines << "- `#{relative}` — #{methods.join(', ')}" if methods.any?
351
+ end
352
+ lines << ""
353
+ rescue
354
+ nil
355
+ end
356
+
357
+ # --- AF9: Environment Dependencies ---
358
+ def discover_env_dependencies(root, pattern, matched_models, lines)
359
+ # Scan services, jobs, and model files for ENV references
360
+ dirs = %w[app/services app/jobs].map { |d| File.join(root, d) }.select { |d| Dir.exist?(d) }
361
+ env_vars = Set.new
362
+
363
+ dirs.each do |dir|
364
+ Dir.glob(File.join(dir, "**", "*.rb")).each do |path|
365
+ next unless File.basename(path, ".rb").include?(pattern) || path.downcase.include?(pattern)
366
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
367
+ source.scan(/ENV\[["']([^"']+)["']\]|ENV\.fetch\(["']([^"']+)["']\)/).each do |m|
368
+ env_vars << (m[0] || m[1])
369
+ end
370
+ end
371
+ end
372
+ return if env_vars.empty?
373
+
374
+ lines << "## Environment Dependencies"
375
+ env_vars.sort.each { |v| lines << "- `#{v}`" }
376
+ lines << ""
377
+ rescue
378
+ nil
379
+ end
112
380
  end
113
381
  end
114
382
  end
@@ -72,6 +72,9 @@ module RailsAiContext
72
72
  # Icons
73
73
  lines.concat(render_icons(patterns))
74
74
 
75
+ # Animations
76
+ lines.concat(render_animations(patterns))
77
+
75
78
  # Design tokens
76
79
  lines.concat(render_tokens(dt))
77
80
  end
@@ -102,12 +105,14 @@ module RailsAiContext
102
105
  lines << "- **Borders:** #{scheme[:border_palette].first(4).join(', ')}"
103
106
  end
104
107
 
105
- # Design token colors
108
+ # Design token colors (DS21: filter oklch noise from summary)
106
109
  if dt.is_a?(Hash) && !dt[:error]
107
110
  colors = dt.dig(:categorized, :colors) || {}
108
- if colors.any?
111
+ # Filter oklch/complex values in non-full modes — show only hex/rgb/named
112
+ readable_colors = colors.reject { |_, v| v.to_s.match?(/oklch|calc|var\(/) }
113
+ if readable_colors.any?
109
114
  lines << "" << "### Token Colors"
110
- colors.first(10).each { |name, value| lines << "- `#{name}`: #{value}" }
115
+ readable_colors.first(10).each { |name, value| lines << "- `#{name}`: #{value}" }
111
116
  end
112
117
  end
113
118
 
@@ -258,6 +263,19 @@ module RailsAiContext
258
263
  lines
259
264
  end
260
265
 
266
+ def render_animations(patterns)
267
+ anims = patterns[:animations]
268
+ return [] unless anims.is_a?(Hash) && anims.any?
269
+
270
+ lines = [ "## Animations & Transitions", "" ]
271
+ lines << "- Transitions: #{anims[:transitions].keys.join(', ')}" if anims[:transitions]&.any?
272
+ lines << "- Durations: #{anims[:durations].keys.join(', ')}" if anims[:durations]&.any?
273
+ lines << "- Animations: #{anims[:animates].keys.join(', ')}" if anims[:animates]&.any?
274
+ lines << "- Easing: #{anims[:easing].keys.join(', ')}" if anims[:easing]&.any?
275
+ lines << ""
276
+ lines
277
+ end
278
+
261
279
  def render_tokens(dt)
262
280
  return [] unless dt.is_a?(Hash) && !dt[:error]
263
281
 
@@ -602,14 +602,17 @@ module RailsAiContext
602
602
  model_data[:associations]&.each { |a| valid << a[:name]; valid << a[:foreign_key] if a[:foreign_key] }
603
603
  valid.merge(%w[id _destroy created_at updated_at])
604
604
 
605
- # If model has JSONB/JSON columns, params may be stored as hash keys inside them skip check
606
- has_json_columns = table_data[:columns]&.any? { |c| %w[jsonb json].include?(c[:type]) }
607
- return warnings if has_json_columns
605
+ # V1: Smarter JSONB skip only skip params matching JSON column names, check the rest
606
+ json_column_names = table_data[:columns]
607
+ &.select { |c| %w[jsonb json].include?(c[:type]) }
608
+ &.map { |c| c[:name] }
609
+ &.to_set || Set.new
608
610
 
609
611
  visitor.permit_calls.each do |pc|
610
612
  pc[:params].each do |param|
611
613
  next if param.end_with?("_attributes") # nested attributes
612
614
  next if valid.include?(param)
615
+ next if json_column_names.include?(param) # param matches a JSON column, skip
613
616
  warnings << "permits :#{param} \u2014 not a column in #{table_name} table"
614
617
  end
615
618
  end
@@ -678,7 +681,7 @@ module RailsAiContext
678
681
  action = route[:action]
679
682
  next unless action
680
683
  unless actions.include?(action)
681
- warnings << "route #{route[:verb]} #{route[:path]} \u2192 #{action} \u2014 action not found in #{ctrl_class}"
684
+ warnings << "route #{route[:verb]} #{route[:path]} \u2192 #{action} \u2014 action not found in #{ctrl_class}. Fix: add `def #{action}; end` to #{ctrl_class} or remove the route"
682
685
  end
683
686
  end
684
687
  warnings
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "1.2.1"
4
+ VERSION = "1.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -184,6 +184,7 @@ files:
184
184
  - CONTRIBUTING.md
185
185
  - LICENSE
186
186
  - README.md
187
+ - ROADMAP.md
187
188
  - Rakefile
188
189
  - SECURITY.md
189
190
  - docs/GUIDE.md