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 +4 -4
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +1 -1
- data/CONTRIBUTING.md +1 -1
- data/README.md +6 -5
- data/SECURITY.md +2 -3
- data/docs/GUIDE.md +31 -4
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/design_token_introspector.rb +28 -6
- data/lib/rails_ai_context/introspectors/model_introspector.rb +2 -1
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +128 -3
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -24
- data/lib/rails_ai_context/serializers/design_system_helper.rb +47 -0
- data/lib/rails_ai_context/tools/analyze_feature.rb +359 -68
- data/lib/rails_ai_context/tools/get_design_system.rb +21 -3
- data/lib/rails_ai_context/tools/validate.rb +4 -2
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d83d1237d476138591f50c4723cb10a4864aed5ed2e84b7059e53bff287af1db
|
|
4
|
+
data.tar.gz: 3791bba4e68364ea507050f2605096ce8dff2cacf943ddd111753b768c8152cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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/ #
|
|
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** (
|
|
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** |
|
|
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
|
-
##
|
|
100
|
+
## 15 Live MCP Tools
|
|
101
101
|
|
|
102
|
-
The gem exposes **
|
|
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` |
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
56
|
+
framework = if gemfile_content.include?("tailwindcss-rails")
|
|
55
57
|
"tailwind"
|
|
56
|
-
elsif
|
|
58
|
+
elsif gemfile_content.include?("bootstrap")
|
|
57
59
|
"bootstrap"
|
|
58
|
-
elsif
|
|
60
|
+
elsif gemfile_content.include?("dartsass-rails") || gemfile_content.include?("sassc-rails") || gemfile_content.include?("sass-rails")
|
|
59
61
|
"sass"
|
|
60
|
-
elsif
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
222
|
-
|
|
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 "
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
lines << "## Models" << "_No models matching '#{feature}'._"
|
|
139
|
+
lines << ""
|
|
73
140
|
end
|
|
74
141
|
|
|
75
|
-
# ---
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 << "- **
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
lines << "## Controllers" << "_No controllers matching '#{feature}'._"
|
|
246
|
+
lines << ""
|
|
91
247
|
end
|
|
92
248
|
|
|
93
|
-
# ---
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
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,
|
|
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"
|