rails-ai-context 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d0ee652e69687c50687cd1b13209688aa5eecc24cf892fa807f9a3d03c7f2de
4
- data.tar.gz: e689153ebb668ead61eb375c551a4724c73ce9338ae78a48b6acae63b9a63266
3
+ metadata.gz: d83d1237d476138591f50c4723cb10a4864aed5ed2e84b7059e53bff287af1db
4
+ data.tar.gz: 3791bba4e68364ea507050f2605096ce8dff2cacf943ddd111753b768c8152cf
5
5
  SHA512:
6
- metadata.gz: 415542e44483875ce828fb3476d7d76a84d3df8a3ad3b4a8e512b649563e74bbc570244c5669d16fad7af574d2eb60c8e07fc743dc0126cfcdb4d728fd5b55f5
7
- data.tar.gz: e8bc26c2edf09e86eee60b70adeee830b4ed9df130a5da5b658f721077d50cb21d496e677fae0dec7059d7416192b9c0ed27727f786ef76a2522aa63be16a025
6
+ metadata.gz: 4e3ceb93281e5d3674aab290728357ef75d9d0c24a7d3cee05c9380333ba4d5209b13fdad85a7f015185697d09b9c9093fafc10e628d4dbc1bb897280eb5c388
7
+ data.tar.gz: 4a0240af3fc7a4ecc303c72107b62f5f5df88fafc189be1f3b19096895dccdd7242ce6e640f7d0f6f148aaed45af7e74f4bf717f773aee3b8cd77107f393c6b0
data/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ 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.1] - 2026-03-23
9
+
10
+ ### Fixed
11
+
12
+ - **Documentation audit** — updated tool count from 14 to 15 across README, GUIDE, CONTRIBUTING, server.json. Added `rails_get_design_system` documentation section to GUIDE.md. Updated SECURITY.md supported versions. Fixed spec count in CLAUDE.md. Added `rails_get_design_system` to README tool table. Updated `rails_analyze_feature` description to reflect full-stack discovery (services, jobs, views, Stimulus, tests, related models, env deps).
13
+ - **analyze_feature crash on complex models** — added type guards (`is_a?(Hash)`, `is_a?(Array)`) to all data access points preventing `no implicit conversion of Symbol into Integer` errors on models with many associations or complex data.
14
+
15
+ ## [1.3.0] - 2026-03-23
16
+
17
+ ### Added
18
+
19
+ - **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.
20
+ - **Modal pattern extraction** (DS1) — detects overlay (`fixed inset-0 bg-black/50`) and modal card patterns
21
+ - **List item pattern extraction** (DS5) — detects repeating card/item patterns from views
22
+ - **Shared partials with descriptions** (DS7) — scans `app/views/shared/` and infers purpose (flash, navbar, status badge, loading, modal, etc.)
23
+ - **"When to use what" decision guide** (DS8) — explicit rules: primary button for CTAs, danger for destructive, when to use shared partials
24
+ - **Bootstrap component extraction** (DS13-DS15) — detects `btn-primary`, `card`, `modal`, `form-control`, `badge`, `alert`, `nav` patterns from Bootstrap apps
25
+ - **Tailwind `@apply` directive parsing** (DS16) — extracts named component classes from CSS `@apply` rules
26
+ - **DaisyUI/Flowbite/Headless UI detection** (DS17) — reports Tailwind plugin libraries from package.json
27
+ - **Animation/transition inventory** (DS19) — extracts `transition-*`, `duration-*`, `animate-*`, `ease-*` patterns
28
+ - **Smarter JSONB strong params check** (V1) — only skips params matching JSON column names, validates the rest
29
+ - **Route-action fix suggestions** (V2) — suggests "add `def action; end`" when route exists but action is missing
30
+
31
+ ### Fixed
32
+
33
+ - **`self` filtered from class methods** (B2/MD1) — no longer appears in model class method lists
34
+ - **Rules serializer methods cap raised to 20** (RS1) — uses introspector's pre-filtered methods directly instead of redundant re-filtering
35
+ - **oklch token noise filtered** (DS21) — complex color values (oklch, calc, var) hidden from summary, only shown in `detail:"full"`
36
+
8
37
  ## [1.2.1] - 2026-03-23
9
38
 
10
39
  ### Fixed
data/CLAUDE.md CHANGED
@@ -45,7 +45,7 @@ structure to AI assistants via the Model Context Protocol (MCP).
45
45
  ## Testing
46
46
 
47
47
  ```bash
48
- bundle exec rspec # Run specs (520 examples)
48
+ bundle exec rspec # Run specs (522 examples)
49
49
  bundle exec rubocop # Lint
50
50
  ```
51
51
 
data/CONTRIBUTING.md CHANGED
@@ -19,7 +19,7 @@ The test suite uses [Combustion](https://github.com/pat/combustion) to boot a mi
19
19
  ```
20
20
  lib/rails_ai_context/
21
21
  ├── introspectors/ # 29 introspectors (schema, models, routes, etc.)
22
- ├── tools/ # 14 MCP tools with detail levels and pagination
22
+ ├── tools/ # 15 MCP tools with detail levels and pagination
23
23
  ├── serializers/ # Per-assistant formatters (claude, opencode, cursor, windsurf, copilot, JSON)
24
24
  ├── server.rb # MCP server setup (stdio + HTTP)
25
25
  ├── live_reload.rb # MCP live reload (file watcher + cache invalidation)
data/README.md CHANGED
@@ -62,7 +62,7 @@ Agent: rails_validate(files:["app/models/cook.rb"], level:"rails") → catches c
62
62
  |-------|-----------------|---------------|------------|
63
63
  | **Static files** (CLAUDE.md, .cursorrules, etc.) | App overview: stack, models, gems, architecture, UI patterns, MCP tool reference | Automatically at session start | ~150 lines, zero tool calls |
64
64
  | **Split rules** (.claude/rules/, .cursor/rules/) | Deep reference: full schema with column types, all model associations/scopes, controller listings | Conditionally — only when editing relevant files | Zero when not needed |
65
- | **Live MCP tools** (14 tools) | Real-time queries: drill into any table, model, controller action, or view on demand. Semantic validation. | On-demand via agent tool calls | ~25-100 lines per call |
65
+ | **Live MCP tools** (15 tools) | Real-time queries: drill into any table, model, controller action, or view on demand. Semantic validation. Design system. | On-demand via agent tool calls | ~25-100 lines per call |
66
66
 
67
67
  **Progressive disclosure:** the agent gets the map for free, reference guides when relevant, and live GPS when building.
68
68
 
@@ -72,7 +72,7 @@ Agent: rails_validate(files:["app/models/cook.rb"], level:"rails") → catches c
72
72
 
73
73
  | Setup | Tokens | What it knows |
74
74
  |-------|--------|---------------|
75
- | **rails-ai-context (full)** | **28,834** | 14 MCP tools + generated docs + split rules |
75
+ | **rails-ai-context (full)** | **28,834** | 15 MCP tools + generated docs + split rules |
76
76
  | rails-ai-context CLAUDE.md only | 33,106 | Generated docs + rules, no MCP tools |
77
77
  | Normal Claude `/init` | 40,700 | Generic CLAUDE.md only |
78
78
  | No rails-ai-context | 45,477 | Nothing — discovers everything from scratch |
@@ -97,9 +97,9 @@ But token savings is the side effect. The real value:
97
97
 
98
98
  ---
99
99
 
100
- ## 14 Live MCP Tools
100
+ ## 15 Live MCP Tools
101
101
 
102
- The gem exposes **14 read-only tools** via MCP that AI clients call on-demand:
102
+ The gem exposes **15 read-only tools** via MCP that AI clients call on-demand:
103
103
 
104
104
  | Tool | What it returns |
105
105
  |------|----------------|
@@ -116,7 +116,8 @@ The gem exposes **14 read-only tools** via MCP that AI clients call on-demand:
116
116
  | `rails_get_stimulus` | Stimulus controllers — targets, values, actions, outlets |
117
117
  | `rails_get_edit_context` | Surgical edit helper — returns code around a match with line numbers |
118
118
  | `rails_validate` | Batch syntax validation for Ruby, ERB, and JavaScript files. `level:"rails"` adds semantic checks (partials, route helpers, columns, strong params, callbacks, FK indexes, Stimulus) |
119
- | `rails_analyze_feature` | End-to-end feature analysis — finds matching models, controllers, routes, and views in one call |
119
+ | `rails_analyze_feature` | Full-stack feature analysis — models, controllers, routes, services, jobs, views, Stimulus, tests, related models, env deps |
120
+ | `rails_get_design_system` | App design system — color palette, component patterns with real HTML examples, typography, layout, responsive breakpoints |
120
121
 
121
122
  ### Smart Detail Levels
122
123
 
data/SECURITY.md CHANGED
@@ -4,10 +4,9 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---------|--------------------|
7
+ | 1.3.x | :white_check_mark: |
7
8
  | 1.2.x | :white_check_mark: |
8
- | 1.1.x | :white_check_mark: |
9
- | 1.0.x | :white_check_mark: |
10
- | < 1.0 | :x: |
9
+ | < 1.2 | :x: |
11
10
 
12
11
  ## Reporting a Vulnerability
13
12
 
data/docs/GUIDE.md CHANGED
@@ -252,7 +252,7 @@ rails ai:context:claude # Use this instead (no quoting needed)
252
252
 
253
253
  ## MCP Tools — Full Reference
254
254
 
255
- All 14 tools are **read-only** and **idempotent** — they never modify your application or database.
255
+ All 15 tools are **read-only** and **idempotent** — they never modify your application or database.
256
256
 
257
257
  ### rails_get_schema
258
258
 
@@ -590,7 +590,7 @@ rails_search_code(pattern: "validates", context_lines: 2)
590
590
 
591
591
  ### rails_analyze_feature
592
592
 
593
- Analyzes a feature end-to-end: finds matching models, controllers, routes, and views in one call.
593
+ Full-stack feature analysis: models, controllers, routes, services, jobs, views, Stimulus controllers, tests, related models, concerns, callbacks, channels, mailers, and environment dependencies in one call.
594
594
 
595
595
  **Parameters:**
596
596
 
@@ -616,7 +616,34 @@ rails_analyze_feature(feature: "orders")
616
616
  → Everything related to orders across all layers
617
617
  ```
618
618
 
619
- **Returns:** Markdown with sections for Models (with table, columns, indexes, FKs, associations, validations, scopes), Controllers (with actions and filters), and Routes (with verbs, paths, and route names). Each section shows match counts.
619
+ **Returns:** Markdown with sections for Models (with columns, associations, validations, scopes, enums), Controllers (with actions and filters), Routes, Services (with methods), Jobs (with queue/retry), Views (with partials and Stimulus refs), Stimulus controllers (with targets/values/actions), Tests (with counts), Related models, Concerns, Callbacks, Channels, Mailers, and Environment dependencies. Each section shows match counts.
620
+
621
+ ### rails_get_design_system
622
+
623
+ Returns the app's design system: color palette with semantic roles, component patterns with real HTML examples from actual views, typography scale, layout conventions, responsive breakpoints, and interactive state patterns.
624
+
625
+ **Parameters:**
626
+
627
+ | Param | Type | Description |
628
+ |-------|------|-------------|
629
+ | `detail` | string | `summary` (palette + components), `standard` (+ canonical page examples + design rules, default), `full` (+ typography, responsive, dark mode, animations, design tokens) |
630
+
631
+ **Examples:**
632
+
633
+ ```
634
+ rails_get_design_system()
635
+ → Color palette (primary, danger, success), component patterns (buttons, cards, inputs),
636
+ canonical page examples (form page, list page), design rules
637
+
638
+ rails_get_design_system(detail: "summary")
639
+ → Compact: color roles + component class strings only
640
+
641
+ rails_get_design_system(detail: "full")
642
+ → Everything: + typography scale, responsive breakpoints, interactive states,
643
+ dark mode patterns, animations, icon system, design tokens, shared partials
644
+ ```
645
+
646
+ **Returns:** Structured design system reference. Includes real HTML/ERB snippets from the app's actual views as canonical examples, semantic color roles (primary for CTAs, danger for destructive), component variants, typography hierarchy, spacing scale, and explicit design rules for AI to follow.
620
647
 
621
648
  ### Detail Level Summary
622
649
 
@@ -746,7 +773,7 @@ RailsAiContext.configure do |config|
746
773
  end
747
774
  ```
748
775
 
749
- Both transports are **read-only** — they expose the same 14 tools and never modify your app.
776
+ Both transports are **read-only** — they expose the same 15 tools and never modify your app.
750
777
 
751
778
  ---
752
779
 
@@ -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,385 @@ 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
+ if data[:associations].is_a?(Array) && data[:associations].any?
78
+ lines << "**Associations:** #{data[:associations].select { |a| a.is_a?(Hash) }.map { |a| "#{a[:type]} :#{a[:name]}" }.join(', ')}"
79
+ end
80
+ if data[:validations].is_a?(Array) && data[:validations].any?
81
+ lines << "**Validations:** #{data[:validations].select { |v| v.is_a?(Hash) }.map { |v| "#{v[:kind]} on #{Array(v[:attributes]).join(', ')}" }.uniq.join('; ')}"
82
+ end
83
+ lines << "**Scopes:** #{data[:scopes].join(', ')}" if data[:scopes].is_a?(Array) && data[:scopes].any?
84
+ if data[:enums].is_a?(Hash) && data[:enums].any?
85
+ lines << "**Enums:** #{data[:enums].map { |k, v| "#{k}: #{Array(v).join(', ')}" }.join('; ')}"
86
+ end
63
87
  end
64
- if data[:validations]&.any?
65
- lines << "**Validations:** #{data[:validations].map { |v| "#{v[:kind]} on #{v[:attributes].join(', ')}" }.uniq.join('; ')}"
88
+ else
89
+ lines << "## Models" << "_No models matching '#{pattern}'._"
90
+ end
91
+
92
+ lines << ""
93
+ matched
94
+ end
95
+
96
+ # --- AF: Controllers ---
97
+ def discover_controllers(ctx, pattern, lines)
98
+ controllers = ctx.dig(:controllers, :controllers) || {}
99
+ matched = controllers.select { |name, data| data.is_a?(Hash) && !data[:error] && name.downcase.include?(pattern) }
100
+
101
+ if matched.any?
102
+ lines << "## Controllers (#{matched.size})"
103
+ matched.sort.each do |name, info|
104
+ actions = info[:actions]&.join(", ") || "none"
105
+ lines << "" << "### #{name}"
106
+ lines << "- **Actions:** #{actions}"
107
+ filters = (info[:filters] || []).select { |f| f.is_a?(Hash) }.map do |f|
108
+ label = "#{f[:kind]} #{f[:name]}"
109
+ label += " only: #{Array(f[:only]).join(', ')}" if f[:only]&.any?
110
+ label += " except: #{Array(f[:except]).join(', ')}" if f[:except]&.any?
111
+ label += " unless: #{f[:unless]}" if f[:unless]
112
+ label
113
+ end
114
+ lines << "- **Filters:** #{filters.join('; ')}" if filters.any?
66
115
  end
67
- if data[:scopes]&.any?
68
- lines << "**Scopes:** #{data[:scopes].join(', ')}"
116
+ else
117
+ lines << "## Controllers" << "_No controllers matching '#{pattern}'._"
118
+ end
119
+ lines << ""
120
+ end
121
+
122
+ # --- AF: Routes ---
123
+ def discover_routes(ctx, pattern, lines)
124
+ by_controller = ctx.dig(:routes, :by_controller) || {}
125
+ matched = by_controller.select { |ctrl, _| ctrl.downcase.include?(pattern) }
126
+
127
+ if matched.any?
128
+ route_count = matched.values.sum(&:size)
129
+ lines << "## Routes (#{route_count})"
130
+ matched.sort.each do |ctrl, actions|
131
+ actions.each do |r|
132
+ name_part = r[:name] ? " `#{r[:name]}`" : ""
133
+ lines << "- `#{r[:verb]}` `#{r[:path]}` → #{ctrl}##{r[:action]}#{name_part}"
134
+ end
69
135
  end
136
+ else
137
+ lines << "## Routes" << "_No routes matching '#{pattern}'._"
70
138
  end
71
- else
72
- lines << "## Models" << "_No models matching '#{feature}'._"
139
+ lines << ""
73
140
  end
74
141
 
75
- # --- Controllers ---
76
- controllers = (ctx.dig(:controllers, :controllers) || {})
77
- matched_controllers = controllers.select { |name, data| !data[:error] && name.downcase.include?(pattern) }
142
+ # --- AF1: Services ---
143
+ def discover_services(root, pattern, lines)
144
+ dir = File.join(root, "app", "services")
145
+ return unless Dir.exist?(dir)
146
+
147
+ found = Dir.glob(File.join(dir, "**", "*.rb")).select do |path|
148
+ File.basename(path, ".rb").include?(pattern) ||
149
+ (File.size(path) < 50_000 && File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace).downcase.include?(pattern))
150
+ end
151
+ return if found.empty?
78
152
 
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(", ")
153
+ lines << "## Services (#{found.size})"
154
+ found.each do |path|
155
+ relative = path.sub("#{root}/", "")
156
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
157
+ line_count = source.lines.size
158
+ methods = source.scan(/\A\s*def (?:self\.)?(\w+)/m).flatten.reject { |m| m == "initialize" }
159
+ lines << "- `#{relative}` (#{line_count} lines)"
160
+ lines << " Methods: #{methods.first(10).join(', ')}" if methods.any?
161
+ end
162
+ lines << ""
163
+ rescue
164
+ nil
165
+ end
166
+
167
+ # --- AF2: Jobs ---
168
+ def discover_jobs(root, pattern, lines)
169
+ dir = File.join(root, "app", "jobs")
170
+ return unless Dir.exist?(dir)
171
+
172
+ found = Dir.glob(File.join(dir, "**", "*.rb")).select do |path|
173
+ File.basename(path, ".rb").include?(pattern)
174
+ end
175
+ return if found.empty?
176
+
177
+ lines << "## Jobs (#{found.size})"
178
+ found.each do |path|
179
+ relative = path.sub("#{root}/", "")
180
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
181
+ queue = source.match(/queue_as\s+[:'"](\w+)/)&.captures&.first || "default"
182
+ retries = source.match(/retry_on.*attempts:\s*(\d+)/)&.captures&.first
183
+ lines << "- `#{relative}` (queue: #{queue}#{retries ? ", retries: #{retries}" : ""})"
184
+ end
185
+ lines << ""
186
+ rescue
187
+ nil
188
+ end
189
+
190
+ # --- AF3: Views + Partials ---
191
+ def discover_views(ctx, root, pattern, lines)
192
+ views_dir = File.join(root, "app", "views")
193
+ return unless Dir.exist?(views_dir)
194
+
195
+ found = Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).select do |path|
196
+ path.sub("#{views_dir}/", "").downcase.include?(pattern)
197
+ end
198
+ return if found.empty?
199
+
200
+ lines << "## Views (#{found.size})"
201
+ found.each do |path|
202
+ relative = path.sub("#{views_dir}/", "")
203
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
204
+ line_count = source.lines.size
205
+ partials = source.scan(/render\s+(?:partial:\s*)?["']([^"']+)["']/).flatten
206
+ stimulus = source.scan(/data-controller=["']([^"']+)["']/).flat_map { |m| m.first.split }
207
+ detail = "- `#{relative}` (#{line_count} lines)"
208
+ detail += " renders: #{partials.join(', ')}" if partials.any?
209
+ detail += " stimulus: #{stimulus.join(', ')}" if stimulus.any?
210
+ lines << detail
211
+ end
212
+ lines << ""
213
+ rescue
214
+ nil
215
+ end
216
+
217
+ # --- AF4: Stimulus Controllers ---
218
+ def discover_stimulus(ctx, pattern, lines)
219
+ stim = ctx[:stimulus]
220
+ return unless stim.is_a?(Hash) && !stim[:error]
221
+
222
+ controllers = stim[:controllers] || []
223
+ matched = controllers.select do |c|
224
+ name = c[:name] || c[:file]&.gsub("_controller.js", "")
225
+ name&.downcase&.include?(pattern)
226
+ end
227
+ return if matched.empty?
228
+
229
+ lines << "## Stimulus Controllers (#{matched.size})"
230
+ matched.each do |c|
231
+ name = c[:name] || c[:file]&.gsub("_controller.js", "")
85
232
  lines << "" << "### #{name}"
86
- lines << "- **Actions:** #{actions}"
87
- lines << "- **Filters:** #{filters}" unless filters.empty?
233
+ lines << "- **Targets:** #{Array(c[:targets]).join(', ')}" if c[:targets]&.any?
234
+ if c[:values]&.any?
235
+ val_strs = if c[:values].is_a?(Array)
236
+ c[:values].select { |v| v.is_a?(Hash) }.map { |v| "#{v[:name]}:#{v[:type]}" }
237
+ elsif c[:values].is_a?(Hash)
238
+ c[:values].map { |k, v| "#{k}:#{v}" }
239
+ else
240
+ []
241
+ end
242
+ lines << "- **Values:** #{val_strs.join(', ')}" if val_strs.any?
243
+ end
244
+ lines << "- **Actions:** #{Array(c[:actions]).join(', ')}" if c[:actions]&.any?
88
245
  end
89
- else
90
- lines << "## Controllers" << "_No controllers matching '#{feature}'._"
246
+ lines << ""
91
247
  end
92
248
 
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}"
249
+ # --- AF5: Tests ---
250
+ def discover_tests(root, pattern, lines)
251
+ test_dirs = [ File.join(root, "spec"), File.join(root, "test") ]
252
+ found = []
253
+
254
+ test_dirs.each do |dir|
255
+ next unless Dir.exist?(dir)
256
+ Dir.glob(File.join(dir, "**", "*_{test,spec}.rb")).each do |path|
257
+ found << path if File.basename(path, ".rb").include?(pattern)
258
+ end
259
+ Dir.glob(File.join(dir, "**", "{test,spec}_*.rb")).each do |path|
260
+ found << path if File.basename(path, ".rb").include?(pattern)
105
261
  end
106
262
  end
107
- else
108
- lines << "## Routes" << "_No routes matching '#{feature}'._"
263
+ found.uniq!
264
+ return if found.empty?
265
+
266
+ lines << "## Tests (#{found.size})"
267
+ found.each do |path|
268
+ relative = path.sub("#{root}/", "")
269
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
270
+ test_count = source.scan(/\b(?:it|test|should)\b/).size
271
+ lines << "- `#{relative}` (#{test_count} tests)"
272
+ end
273
+ lines << ""
274
+ rescue
275
+ nil
109
276
  end
110
277
 
111
- text_response(lines.join("\n"))
278
+ # --- AF6: Related Models via Associations ---
279
+ def discover_related_models(ctx, matched_models, lines)
280
+ return if matched_models.empty?
281
+
282
+ related = {}
283
+ matched_models.each do |name, data|
284
+ next unless data.is_a?(Hash)
285
+ (data[:associations] || []).each do |a|
286
+ next unless a.is_a?(Hash)
287
+ related_name = a[:class_name] || a[:name].to_s.classify
288
+ next if matched_models.key?(related_name)
289
+ related[related_name] ||= []
290
+ related[related_name] << "#{a[:type]} from #{name}"
291
+ end
292
+ end
293
+ return if related.empty?
294
+
295
+ lines << "## Related Models (#{related.size})"
296
+ related.sort.each { |name, refs| lines << "- **#{name}** — #{refs.join(', ')}" }
297
+ lines << ""
298
+ end
299
+
300
+ # --- AF12: Concern Tracing ---
301
+ def discover_concerns(ctx, matched_models, lines)
302
+ return if matched_models.empty?
303
+
304
+ concerns = {}
305
+ matched_models.each do |_name, data|
306
+ next unless data.is_a?(Hash)
307
+ (data[:concerns] || []).each do |c|
308
+ next unless c.is_a?(String)
309
+ next if c.include?("::") || %w[Kernel JSON PP].include?(c)
310
+ concerns[c] ||= 0
311
+ concerns[c] += 1
312
+ end
313
+ end
314
+ return if concerns.empty?
315
+
316
+ lines << "## Concerns"
317
+ concerns.sort.each { |name, count| lines << "- **#{name}** (used by #{count} model#{'s' if count > 1})" }
318
+ lines << ""
319
+ rescue
320
+ nil
321
+ end
322
+
323
+ # --- AF13: Callback Chains ---
324
+ def discover_callbacks(ctx, matched_models, lines)
325
+ return if matched_models.empty?
326
+
327
+ callbacks = []
328
+ matched_models.each do |name, data|
329
+ next unless data.is_a?(Hash)
330
+ (data[:callbacks] || {}).each do |type, methods|
331
+ next unless methods.is_a?(Array)
332
+ methods.each { |m| callbacks << "#{name}: #{type} :#{m}" }
333
+ end
334
+ end
335
+ return if callbacks.empty?
336
+
337
+ lines << "## Callbacks"
338
+ callbacks.each { |c| lines << "- #{c}" }
339
+ lines << ""
340
+ end
341
+
342
+ # --- AF10: Channels/WebSocket ---
343
+ def discover_channels(root, pattern, lines)
344
+ dir = File.join(root, "app", "channels")
345
+ return unless Dir.exist?(dir)
346
+
347
+ found = Dir.glob(File.join(dir, "**", "*.rb")).select { |p| File.basename(p, ".rb").include?(pattern) }
348
+ return if found.empty?
349
+
350
+ lines << "## Channels (#{found.size})"
351
+ found.each do |path|
352
+ relative = path.sub("#{root}/", "")
353
+ lines << "- `#{relative}`"
354
+ end
355
+ lines << ""
356
+ rescue
357
+ nil
358
+ end
359
+
360
+ # --- AF11: Mailers ---
361
+ def discover_mailers(root, pattern, lines)
362
+ dir = File.join(root, "app", "mailers")
363
+ return unless Dir.exist?(dir)
364
+
365
+ found = Dir.glob(File.join(dir, "**", "*.rb")).select { |p| File.basename(p, ".rb").include?(pattern) }
366
+ return if found.empty?
367
+
368
+ lines << "## Mailers (#{found.size})"
369
+ found.each do |path|
370
+ relative = path.sub("#{root}/", "")
371
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
372
+ methods = source.scan(/\A\s*def (\w+)/m).flatten.reject { |m| m == "initialize" }
373
+ lines << "- `#{relative}` — #{methods.join(', ')}" if methods.any?
374
+ end
375
+ lines << ""
376
+ rescue
377
+ nil
378
+ end
379
+
380
+ # --- AF9: Environment Dependencies ---
381
+ def discover_env_dependencies(root, pattern, matched_models, lines)
382
+ # Scan services, jobs, and model files for ENV references
383
+ dirs = %w[app/services app/jobs].map { |d| File.join(root, d) }.select { |d| Dir.exist?(d) }
384
+ env_vars = Set.new
385
+
386
+ dirs.each do |dir|
387
+ Dir.glob(File.join(dir, "**", "*.rb")).each do |path|
388
+ next unless File.basename(path, ".rb").include?(pattern) || path.downcase.include?(pattern)
389
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
390
+ source.scan(/ENV\[["']([^"']+)["']\]|ENV\.fetch\(["']([^"']+)["']\)/).each do |m|
391
+ env_vars << (m[0] || m[1])
392
+ end
393
+ end
394
+ end
395
+ return if env_vars.empty?
396
+
397
+ lines << "## Environment Dependencies"
398
+ env_vars.sort.each { |v| lines << "- `#{v}`" }
399
+ lines << ""
400
+ rescue
401
+ nil
402
+ end
112
403
  end
113
404
  end
114
405
  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,7 +602,9 @@ 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
605
+ # If model has JSONB/JSON columns, permitted params likely go INTO those columns
606
+ # (e.g. Cook permits :product_details which is stored inside intake JSONB)
607
+ # Skip the entire check — too many false positives otherwise
606
608
  has_json_columns = table_data[:columns]&.any? { |c| %w[jsonb json].include?(c[:type]) }
607
609
  return warnings if has_json_columns
608
610
 
@@ -678,7 +680,7 @@ module RailsAiContext
678
680
  action = route[:action]
679
681
  next unless action
680
682
  unless actions.include?(action)
681
- warnings << "route #{route[:verb]} #{route[:path]} \u2192 #{action} \u2014 action not found in #{ctrl_class}"
683
+ 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
684
  end
683
685
  end
684
686
  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.1"
5
5
  end
data/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.crisnahine/rails-ai-context",
4
4
  "title": "Rails AI Context",
5
- "description": "Auto-expose Rails app structure to AI via MCP. Zero config, 14 read-only tools.",
5
+ "description": "Auto-expose Rails app structure to AI via MCP. Zero config, 15 read-only tools, design system extraction.",
6
6
  "repository": {
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine