rails-ai-context 0.15.3 → 0.15.4
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 +17 -0
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -2
- data/lib/rails_ai_context/tools/get_controllers.rb +40 -12
- data/lib/rails_ai_context/tools/get_model_details.rb +38 -11
- data/lib/rails_ai_context/tools/get_schema.rb +18 -9
- data/lib/rails_ai_context/tools/get_stimulus.rb +36 -16
- data/lib/rails_ai_context/tools/get_view.rb +9 -4
- data/lib/rails_ai_context/tools/search_code.rb +1 -1
- data/lib/rails_ai_context/version.rb +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: df16cb513ad22c8fca7405fed9a9b111e7018184e1bd16cd08230dace76a9612
|
|
4
|
+
data.tar.gz: c4846d86197db0fe697b21cd23ead829a4433abfe444b0a14695433a5ab3d0f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dd8e4f99421b6efbc1b589734c03dbf3f167ec6a2779957a357f2924a9ac3bccd3d40c5c8aca442d92e9392cfed40d517b20455f7e9a9dd2f792c543c3c13928
|
|
7
|
+
data.tar.gz: f970c8cf22b53531143352ea4a8a90d615dbe7530a2a70eabd04dd7fd9e532119a803260681dbb431278ad6bfdc7103e82b487840514afd0c49b4c792e3ce8df
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
## [0.15.4] - 2026-03-22
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **View subfolder paths** — listings now show full relative paths (`bonus/brand_profiles/index.html.erb`) instead of just basenames.
|
|
13
|
+
- **Controller flexible matching** — `"cooks"`, `"CooksController"`, `"cookscontroller"` all resolve (matches other tools' forgiving lookup).
|
|
14
|
+
- **View path traversal** — explicit `..` and absolute path rejection before any filesystem operation.
|
|
15
|
+
- **Schema case-insensitive** — table lookup now case-insensitive (matches models/routes/etc.).
|
|
16
|
+
- **limit:0 silent empty** — uses default instead of returning empty results.
|
|
17
|
+
- **offset past end** — shows "Use `offset:0` to start over" instead of empty response.
|
|
18
|
+
- **Search ordering** — deterministic results via `--sort=path` on ripgrep.
|
|
19
|
+
- **Generated context prepended** — `<!-- BEGIN rails-ai-context -->` section now placed at top of existing files (AI reads top-to-bottom, may truncate at token limits).
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Pagination on models, controllers, stimulus** — `limit`/`offset` params (default 50) with "end of results" hints. Prevents token bombs on large apps.
|
|
24
|
+
|
|
8
25
|
## [0.15.3] - 2026-03-22
|
|
9
26
|
|
|
10
27
|
### Fixed
|
|
@@ -107,8 +107,8 @@ module RailsAiContext
|
|
|
107
107
|
marked_content
|
|
108
108
|
)
|
|
109
109
|
else
|
|
110
|
-
# File exists without markers —
|
|
111
|
-
"#{
|
|
110
|
+
# File exists without markers — prepend our section so AI reads it first
|
|
111
|
+
"#{marked_content}\n#{existing}"
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
if new_content == existing
|
|
@@ -20,13 +20,21 @@ module RailsAiContext
|
|
|
20
20
|
type: "string",
|
|
21
21
|
enum: %w[summary standard full],
|
|
22
22
|
description: "Detail level for controller listing. summary: names + action counts. standard: names + action list (default). full: everything. Ignored when specific controller is given."
|
|
23
|
+
},
|
|
24
|
+
limit: {
|
|
25
|
+
type: "integer",
|
|
26
|
+
description: "Max controllers to return when listing. Default: 50."
|
|
27
|
+
},
|
|
28
|
+
offset: {
|
|
29
|
+
type: "integer",
|
|
30
|
+
description: "Skip this many controllers for pagination. Default: 0."
|
|
23
31
|
}
|
|
24
32
|
}
|
|
25
33
|
)
|
|
26
34
|
|
|
27
35
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
28
36
|
|
|
29
|
-
def self.call(controller: nil, action: nil, detail: "standard", server_context: nil)
|
|
37
|
+
def self.call(controller: nil, action: nil, detail: "standard", limit: nil, offset: 0, server_context: nil)
|
|
30
38
|
data = cached_context[:controllers]
|
|
31
39
|
return text_response("Controller introspection not available. Add :controllers to introspectors.") unless data
|
|
32
40
|
return text_response("Controller introspection failed: #{data[:error]}") if data[:error]
|
|
@@ -38,8 +46,13 @@ module RailsAiContext
|
|
|
38
46
|
app_controller_names = controllers.keys.reject { |name| framework_controllers.include?(name) }.sort
|
|
39
47
|
|
|
40
48
|
# Specific controller — always full detail (searches ALL controllers including framework)
|
|
49
|
+
# Flexible matching: "cooks", "CooksController", "cookscontroller" all work
|
|
41
50
|
if controller
|
|
42
|
-
|
|
51
|
+
normalized = controller.downcase.delete_suffix("controller")
|
|
52
|
+
key = controllers.keys.find { |k|
|
|
53
|
+
kd = k.downcase
|
|
54
|
+
kd == controller.downcase || kd.delete_suffix("controller") == normalized
|
|
55
|
+
} || controller
|
|
43
56
|
info = controllers[key]
|
|
44
57
|
return text_response("Controller '#{controller}' not found. Available: #{app_controller_names.join(', ')}") unless info
|
|
45
58
|
return text_response("Error inspecting #{key}: #{info[:error]}") if info[:error]
|
|
@@ -54,33 +67,47 @@ module RailsAiContext
|
|
|
54
67
|
|
|
55
68
|
app_controllers = controllers.reject { |name, _| framework_controllers.include?(name) }
|
|
56
69
|
|
|
70
|
+
# Pagination
|
|
71
|
+
total = app_controllers.size
|
|
72
|
+
offset = [ offset.to_i, 0 ].max
|
|
73
|
+
limit = limit.nil? ? 50 : [ limit.to_i, 1 ].max
|
|
74
|
+
all_names = app_controllers.keys.sort
|
|
75
|
+
paginated_names = all_names.drop(offset).first(limit)
|
|
76
|
+
|
|
77
|
+
if paginated_names.empty? && total > 0
|
|
78
|
+
return text_response("No controllers at offset #{offset}. Total: #{total}. Use `offset:0` to start over.")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
pagination_hint = offset + limit < total ? "\n_Showing #{paginated_names.size} of #{total}. Use `offset:#{offset + limit}` for more._" : ""
|
|
82
|
+
|
|
57
83
|
# Listing mode
|
|
58
84
|
case detail
|
|
59
85
|
when "summary"
|
|
60
|
-
lines = [ "# Controllers (#{
|
|
61
|
-
|
|
86
|
+
lines = [ "# Controllers (#{total})", "" ]
|
|
87
|
+
paginated_names.each do |name|
|
|
62
88
|
info = app_controllers[name]
|
|
63
89
|
action_count = info[:actions]&.size || 0
|
|
64
90
|
lines << "- **#{name}** — #{action_count} actions"
|
|
65
91
|
end
|
|
66
|
-
lines << "" << "_Use `controller:\"Name\"` for full detail._"
|
|
92
|
+
lines << "" << "_Use `controller:\"Name\"` for full detail._#{pagination_hint}"
|
|
67
93
|
text_response(lines.join("\n"))
|
|
68
94
|
|
|
69
95
|
when "standard"
|
|
70
|
-
lines = [ "# Controllers (#{
|
|
71
|
-
|
|
96
|
+
lines = [ "# Controllers (#{total})", "" ]
|
|
97
|
+
paginated_names.each do |name|
|
|
72
98
|
info = app_controllers[name]
|
|
73
99
|
actions = info[:actions]&.join(", ") || "none"
|
|
74
100
|
lines << "- **#{name}** — #{actions}"
|
|
75
101
|
end
|
|
76
|
-
lines << "" << "_Use `controller:\"Name\"` for filters and strong params, or `detail:\"full\"` for everything._"
|
|
102
|
+
lines << "" << "_Use `controller:\"Name\"` for filters and strong params, or `detail:\"full\"` for everything._#{pagination_hint}"
|
|
77
103
|
text_response(lines.join("\n"))
|
|
78
104
|
|
|
79
105
|
when "full"
|
|
80
|
-
lines = [ "# Controllers (#{
|
|
106
|
+
lines = [ "# Controllers (#{total})", "" ]
|
|
81
107
|
|
|
82
108
|
# Group sibling controllers that share the same parent and identical structure
|
|
83
|
-
|
|
109
|
+
paginated_ctrl = app_controllers.select { |k, _| paginated_names.include?(k) }
|
|
110
|
+
grouped = paginated_ctrl.keys.sort.group_by do |name|
|
|
84
111
|
info = app_controllers[name]
|
|
85
112
|
parent = info[:parent_class]
|
|
86
113
|
# Group by parent + actions + filters + params fingerprint
|
|
@@ -121,11 +148,12 @@ module RailsAiContext
|
|
|
121
148
|
end
|
|
122
149
|
end
|
|
123
150
|
end
|
|
151
|
+
lines << pagination_hint unless pagination_hint.empty?
|
|
124
152
|
text_response(lines.join("\n"))
|
|
125
153
|
|
|
126
154
|
else
|
|
127
|
-
list =
|
|
128
|
-
text_response("# Controllers (#{
|
|
155
|
+
list = paginated_names.map { |c| "- #{c}" }.join("\n")
|
|
156
|
+
text_response("# Controllers (#{total})\n\n#{list}#{pagination_hint}")
|
|
129
157
|
end
|
|
130
158
|
end
|
|
131
159
|
|
|
@@ -16,13 +16,21 @@ module RailsAiContext
|
|
|
16
16
|
type: "string",
|
|
17
17
|
enum: %w[summary standard full],
|
|
18
18
|
description: "Detail level for model listing. summary: names only. standard: names + association/validation counts (default). full: names + full association list. Ignored when specific model is given (always returns full)."
|
|
19
|
+
},
|
|
20
|
+
limit: {
|
|
21
|
+
type: "integer",
|
|
22
|
+
description: "Max models to return when listing. Default: 50."
|
|
23
|
+
},
|
|
24
|
+
offset: {
|
|
25
|
+
type: "integer",
|
|
26
|
+
description: "Skip this many models for pagination. Default: 0."
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
29
|
)
|
|
22
30
|
|
|
23
31
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
24
32
|
|
|
25
|
-
def self.call(model: nil, detail: "standard", server_context: nil)
|
|
33
|
+
def self.call(model: nil, detail: "standard", limit: nil, offset: 0, server_context: nil)
|
|
26
34
|
models = cached_context[:models]
|
|
27
35
|
return text_response("Model introspection not available. Add :models to introspectors.") unless models
|
|
28
36
|
return text_response("Model introspection failed: #{models[:error]}") if models[:error]
|
|
@@ -36,15 +44,28 @@ module RailsAiContext
|
|
|
36
44
|
return text_response(format_model(key, data))
|
|
37
45
|
end
|
|
38
46
|
|
|
47
|
+
# Pagination
|
|
48
|
+
total = models.size
|
|
49
|
+
offset = [ offset.to_i, 0 ].max
|
|
50
|
+
limit = normalize_limit(limit, 50)
|
|
51
|
+
all_names = models.keys.sort
|
|
52
|
+
paginated = all_names.drop(offset).first(limit)
|
|
53
|
+
|
|
54
|
+
if paginated.empty? && total > 0
|
|
55
|
+
return text_response("No models at offset #{offset}. Total: #{total}. Use `offset:0` to start over.")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
pagination_hint = offset + limit < total ? "\n_Showing #{paginated.size} of #{total}. Use `offset:#{offset + limit}` for more._" : ""
|
|
59
|
+
|
|
39
60
|
# Listing mode
|
|
40
61
|
case detail
|
|
41
62
|
when "summary"
|
|
42
|
-
model_list =
|
|
43
|
-
text_response("# Available models (#{
|
|
63
|
+
model_list = paginated.map { |m| "- #{m}" }.join("\n")
|
|
64
|
+
text_response("# Available models (#{total})\n\n#{model_list}\n\n_Use `model:\"Name\"` for full detail._#{pagination_hint}")
|
|
44
65
|
|
|
45
66
|
when "standard"
|
|
46
|
-
lines = [ "# Models (#{
|
|
47
|
-
|
|
67
|
+
lines = [ "# Models (#{total})", "" ]
|
|
68
|
+
paginated.each do |name|
|
|
48
69
|
data = models[name]
|
|
49
70
|
next if data[:error]
|
|
50
71
|
assoc_count = (data[:associations] || []).size
|
|
@@ -53,12 +74,12 @@ module RailsAiContext
|
|
|
53
74
|
line += " — #{assoc_count} associations, #{val_count} validations" if assoc_count > 0 || val_count > 0
|
|
54
75
|
lines << line
|
|
55
76
|
end
|
|
56
|
-
lines << "" << "_Use `model:\"Name\"` for full detail, or `detail:\"full\"` for association lists._"
|
|
77
|
+
lines << "" << "_Use `model:\"Name\"` for full detail, or `detail:\"full\"` for association lists._#{pagination_hint}"
|
|
57
78
|
text_response(lines.join("\n"))
|
|
58
79
|
|
|
59
80
|
when "full"
|
|
60
|
-
lines = [ "# Models (#{
|
|
61
|
-
|
|
81
|
+
lines = [ "# Models (#{total})", "" ]
|
|
82
|
+
paginated.each do |name|
|
|
62
83
|
data = models[name]
|
|
63
84
|
next if data[:error]
|
|
64
85
|
assocs = (data[:associations] || []).map { |a| "#{a[:type]} :#{a[:name]}" }.join(", ")
|
|
@@ -67,15 +88,21 @@ module RailsAiContext
|
|
|
67
88
|
line += " — #{assocs}" unless assocs.empty?
|
|
68
89
|
lines << line
|
|
69
90
|
end
|
|
70
|
-
lines << "" << "_Use `model:\"Name\"` for validations, scopes, callbacks, and more._"
|
|
91
|
+
lines << "" << "_Use `model:\"Name\"` for validations, scopes, callbacks, and more._#{pagination_hint}"
|
|
71
92
|
text_response(lines.join("\n"))
|
|
72
93
|
|
|
73
94
|
else
|
|
74
|
-
model_list =
|
|
75
|
-
text_response("# Available models (#{
|
|
95
|
+
model_list = paginated.map { |m| "- #{m}" }.join("\n")
|
|
96
|
+
text_response("# Available models (#{total})\n\n#{model_list}#{pagination_hint}")
|
|
76
97
|
end
|
|
77
98
|
end
|
|
78
99
|
|
|
100
|
+
private_class_method def self.normalize_limit(limit, default)
|
|
101
|
+
return default if limit.nil?
|
|
102
|
+
val = limit.to_i
|
|
103
|
+
val < 1 ? default : val
|
|
104
|
+
end
|
|
105
|
+
|
|
79
106
|
private_class_method def self.format_model(name, data)
|
|
80
107
|
lines = [ "# #{name}", "" ]
|
|
81
108
|
lines << "**Table:** `#{data[:table_name]}`" if data[:table_name]
|
|
@@ -47,24 +47,29 @@ module RailsAiContext
|
|
|
47
47
|
|
|
48
48
|
tables = schema[:tables] || {}
|
|
49
49
|
|
|
50
|
-
# Single table — always full detail (existing behavior)
|
|
51
|
-
if table
|
|
52
|
-
table_data = tables[table]
|
|
53
|
-
return text_response("Table '#{table}' not found. Available: #{tables.keys.sort.join(', ')}") unless table_data
|
|
54
|
-
output = format == "json" ? table_data.to_json : format_table_markdown(table, table_data)
|
|
55
|
-
return text_response(output)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
50
|
# Return full JSON if requested (existing behavior)
|
|
59
51
|
return text_response(schema.to_json) if format == "json" && detail == "full"
|
|
60
52
|
|
|
61
53
|
total = tables.size
|
|
62
54
|
offset = [ offset.to_i, 0 ].max
|
|
63
55
|
|
|
56
|
+
# Single table — case-insensitive lookup
|
|
57
|
+
if table
|
|
58
|
+
table_key = tables.keys.find { |k| k.downcase == table.downcase } || table
|
|
59
|
+
table_data = tables[table_key]
|
|
60
|
+
return text_response("Table '#{table}' not found. Available: #{tables.keys.sort.join(', ')}") unless table_data
|
|
61
|
+
output = format == "json" ? table_data.to_json : format_table_markdown(table_key, table_data)
|
|
62
|
+
return text_response(output)
|
|
63
|
+
end
|
|
64
|
+
|
|
64
65
|
case detail
|
|
65
66
|
when "summary"
|
|
66
67
|
limit ||= 50
|
|
68
|
+
limit = 50 if limit.to_i < 1
|
|
67
69
|
paginated = tables.keys.sort.drop(offset).first(limit)
|
|
70
|
+
if paginated.empty? && total > 0
|
|
71
|
+
return text_response("No tables at offset #{offset}. Total: #{total}. Use `offset:0` to start over.")
|
|
72
|
+
end
|
|
68
73
|
lines = [ "# Schema Summary (#{total} tables)", "" ]
|
|
69
74
|
paginated.each do |name|
|
|
70
75
|
data = tables[name]
|
|
@@ -77,6 +82,7 @@ module RailsAiContext
|
|
|
77
82
|
|
|
78
83
|
when "standard"
|
|
79
84
|
limit ||= 15
|
|
85
|
+
limit = 15 if limit.to_i < 1
|
|
80
86
|
paginated = tables.keys.sort.drop(offset).first(limit)
|
|
81
87
|
if paginated.empty?
|
|
82
88
|
return text_response("No tables at offset #{offset}. Total tables: #{total}. Use `offset:0` to start from the beginning.")
|
|
@@ -96,9 +102,12 @@ module RailsAiContext
|
|
|
96
102
|
text_response(lines.join("\n"))
|
|
97
103
|
|
|
98
104
|
when "full"
|
|
99
|
-
# Existing behavior — but now with optional pagination
|
|
100
105
|
limit ||= 5
|
|
106
|
+
limit = 5 if limit.to_i < 1
|
|
101
107
|
paginated = tables.keys.sort.drop(offset).first(limit)
|
|
108
|
+
if paginated.empty? && total > 0
|
|
109
|
+
return text_response("No tables at offset #{offset}. Total: #{total}. Use `offset:0` to start over.")
|
|
110
|
+
end
|
|
102
111
|
lines = [ "# Schema Full Detail (#{paginated.size} of #{total} tables)", "" ]
|
|
103
112
|
paginated.each do |name|
|
|
104
113
|
lines << format_table_markdown(name, tables[name])
|
|
@@ -16,36 +16,56 @@ module RailsAiContext
|
|
|
16
16
|
type: "string",
|
|
17
17
|
enum: %w[summary standard full],
|
|
18
18
|
description: "Detail level. summary: names + counts. standard: names + targets + actions (default). full: everything including values, outlets, classes."
|
|
19
|
+
},
|
|
20
|
+
limit: {
|
|
21
|
+
type: "integer",
|
|
22
|
+
description: "Max controllers to return when listing. Default: 50."
|
|
23
|
+
},
|
|
24
|
+
offset: {
|
|
25
|
+
type: "integer",
|
|
26
|
+
description: "Skip this many controllers for pagination. Default: 0."
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
29
|
)
|
|
22
30
|
|
|
23
31
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
24
32
|
|
|
25
|
-
def self.call(controller: nil, detail: "standard", server_context: nil)
|
|
33
|
+
def self.call(controller: nil, detail: "standard", limit: nil, offset: 0, server_context: nil)
|
|
26
34
|
data = cached_context[:stimulus]
|
|
27
35
|
return text_response("Stimulus introspection not available. Add :stimulus to introspectors.") unless data
|
|
28
36
|
return text_response("Stimulus introspection failed: #{data[:error]}") if data[:error]
|
|
29
37
|
|
|
30
|
-
|
|
31
|
-
return text_response("No Stimulus controllers found.") if
|
|
38
|
+
all_controllers = data[:controllers] || []
|
|
39
|
+
return text_response("No Stimulus controllers found.") if all_controllers.empty?
|
|
32
40
|
|
|
33
41
|
# Specific controller — accepts both dash and underscore naming
|
|
34
42
|
# (HTML uses data-controller="weekly-chart", file is weekly_chart_controller.js)
|
|
35
43
|
if controller
|
|
36
44
|
normalized = controller.downcase.tr("-", "_")
|
|
37
|
-
ctrl =
|
|
38
|
-
return text_response("Controller '#{controller}' not found. Available: #{
|
|
45
|
+
ctrl = all_controllers.find { |c| c[:name]&.downcase&.tr("-", "_") == normalized }
|
|
46
|
+
return text_response("Controller '#{controller}' not found. Available: #{all_controllers.map { |c| c[:name] }.sort.join(', ')}\n\n_Note: use dashes in HTML (`data-controller=\"my-name\"`) but underscores for lookup (`controller:\"my_name\"`)._") unless ctrl
|
|
39
47
|
return text_response(format_controller_full(ctrl))
|
|
40
48
|
end
|
|
41
49
|
|
|
50
|
+
# Pagination
|
|
51
|
+
total = all_controllers.size
|
|
52
|
+
offset_val = [ offset.to_i, 0 ].max
|
|
53
|
+
limit_val = limit.nil? ? 50 : [ limit.to_i, 1 ].max
|
|
54
|
+
sorted_all = all_controllers.sort_by { |c| c[:name]&.to_s || "" }
|
|
55
|
+
controllers = sorted_all.drop(offset_val).first(limit_val)
|
|
56
|
+
|
|
57
|
+
if controllers.empty? && total > 0
|
|
58
|
+
return text_response("No controllers at offset #{offset_val}. Total: #{total}. Use `offset:0` to start over.")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
pagination_hint = offset_val + limit_val < total ? "\n_Showing #{controllers.size} of #{total}. Use `offset:#{offset_val + limit_val}` for more._" : ""
|
|
62
|
+
|
|
42
63
|
case detail
|
|
43
64
|
when "summary"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
empty = sorted.reject { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
|
|
65
|
+
active = controllers.select { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
|
|
66
|
+
empty = controllers.reject { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
|
|
47
67
|
|
|
48
|
-
lines = [ "# Stimulus Controllers (#{
|
|
68
|
+
lines = [ "# Stimulus Controllers (#{total})", "" ]
|
|
49
69
|
active.each do |ctrl|
|
|
50
70
|
targets = (ctrl[:targets] || []).size
|
|
51
71
|
actions = (ctrl[:actions] || []).size
|
|
@@ -55,15 +75,14 @@ module RailsAiContext
|
|
|
55
75
|
names = empty.map { |c| c[:name] }.join(", ")
|
|
56
76
|
lines << "- _#{names}_ (lifecycle only)"
|
|
57
77
|
end
|
|
58
|
-
lines << "" << "_Use `controller:\"name\"` for full detail._"
|
|
78
|
+
lines << "" << "_Use `controller:\"name\"` for full detail._#{pagination_hint}"
|
|
59
79
|
text_response(lines.join("\n"))
|
|
60
80
|
|
|
61
81
|
when "standard"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
empty = sorted.reject { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
|
|
82
|
+
active = controllers.select { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
|
|
83
|
+
empty = controllers.reject { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
|
|
65
84
|
|
|
66
|
-
lines = [ "# Stimulus Controllers (#{
|
|
85
|
+
lines = [ "# Stimulus Controllers (#{total})", "" ]
|
|
67
86
|
active.each do |ctrl|
|
|
68
87
|
lines << "## #{ctrl[:name]}"
|
|
69
88
|
lines << "- Targets: #{(ctrl[:targets] || []).join(', ')}" if ctrl[:targets]&.any?
|
|
@@ -74,12 +93,13 @@ module RailsAiContext
|
|
|
74
93
|
names = empty.map { |c| c[:name] }.join(", ")
|
|
75
94
|
lines << "_Lifecycle only (no targets/actions): #{names}_"
|
|
76
95
|
end
|
|
96
|
+
lines << pagination_hint unless pagination_hint.empty?
|
|
77
97
|
text_response(lines.join("\n"))
|
|
78
98
|
|
|
79
99
|
when "full"
|
|
80
|
-
lines = [ "# Stimulus Controllers (#{
|
|
100
|
+
lines = [ "# Stimulus Controllers (#{total})", "" ]
|
|
81
101
|
lines << "_HTML naming: `data-controller=\"my-name\"` (dashes in HTML, underscores in filenames)_" << ""
|
|
82
|
-
controllers.
|
|
102
|
+
controllers.each do |ctrl|
|
|
83
103
|
lines << format_controller_full(ctrl) << ""
|
|
84
104
|
end
|
|
85
105
|
text_response(lines.join("\n"))
|
|
@@ -70,10 +70,10 @@ module RailsAiContext
|
|
|
70
70
|
ctrl_templates.sort.each do |name, meta|
|
|
71
71
|
parts = meta[:partials]&.any? ? " renders: #{meta[:partials].join(', ')}" : ""
|
|
72
72
|
stim = meta[:stimulus]&.any? ? " stimulus: #{meta[:stimulus].join(', ')}" : ""
|
|
73
|
-
lines << "- #{
|
|
73
|
+
lines << "- #{name} (#{meta[:lines]} lines)#{parts}#{stim}"
|
|
74
74
|
end
|
|
75
75
|
ctrl_partials.sort.each do |name, meta|
|
|
76
|
-
lines << "- #{
|
|
76
|
+
lines << "- #{name} (#{meta[:lines]} lines)"
|
|
77
77
|
end
|
|
78
78
|
lines << ""
|
|
79
79
|
end
|
|
@@ -91,12 +91,12 @@ module RailsAiContext
|
|
|
91
91
|
ctrl_templates.sort.each do |name, meta|
|
|
92
92
|
parts = meta[:partials]&.any? ? " renders: #{meta[:partials].join(', ')}" : ""
|
|
93
93
|
stim = meta[:stimulus]&.any? ? " stimulus: #{meta[:stimulus].join(', ')}" : ""
|
|
94
|
-
lines << "- #{
|
|
94
|
+
lines << "- #{name} (#{meta[:lines]} lines)#{parts}#{stim}"
|
|
95
95
|
end
|
|
96
96
|
ctrl_partials.sort.each do |name, meta|
|
|
97
97
|
fields = meta[:fields]&.any? ? " fields: #{meta[:fields].join(', ')}" : ""
|
|
98
98
|
helpers = meta[:helpers]&.any? ? " helpers: #{meta[:helpers].join(', ')}" : ""
|
|
99
|
-
lines << "- #{
|
|
99
|
+
lines << "- #{name} (#{meta[:lines]} lines)#{fields}#{helpers}"
|
|
100
100
|
end
|
|
101
101
|
lines << ""
|
|
102
102
|
end
|
|
@@ -138,6 +138,11 @@ module RailsAiContext
|
|
|
138
138
|
MAX_FILE_SIZE = 2_000_000 # 2MB safety limit
|
|
139
139
|
|
|
140
140
|
private_class_method def self.read_view_file(path)
|
|
141
|
+
# Reject path traversal attempts before any filesystem operation
|
|
142
|
+
if path.include?("..") || path.start_with?("/")
|
|
143
|
+
return text_response("Path not allowed: #{path}")
|
|
144
|
+
end
|
|
145
|
+
|
|
141
146
|
views_dir = Rails.root.join("app", "views")
|
|
142
147
|
full_path = views_dir.join(path)
|
|
143
148
|
|
|
@@ -90,7 +90,7 @@ module RailsAiContext
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
private_class_method def self.search_with_ripgrep(pattern, search_path, file_type, max_results, root, ctx_lines = 0)
|
|
93
|
-
cmd = [ "rg", "--no-heading", "--line-number", "--max-count", max_results.to_s ]
|
|
93
|
+
cmd = [ "rg", "--no-heading", "--line-number", "--sort=path", "--max-count", max_results.to_s ]
|
|
94
94
|
if ctx_lines > 0
|
|
95
95
|
cmd.push("-C", ctx_lines.to_s)
|
|
96
96
|
# Use colon separator for context lines so parse_rg_output handles them correctly
|