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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 688db0da1f692aa30e3e0129f13819bb00959f64306c4bb2efabf3ced84dd7f0
4
- data.tar.gz: 3338568f68df0f0a74f312e234138580d7b20f607adc6404709c0910580d90c5
3
+ metadata.gz: df16cb513ad22c8fca7405fed9a9b111e7018184e1bd16cd08230dace76a9612
4
+ data.tar.gz: c4846d86197db0fe697b21cd23ead829a4433abfe444b0a14695433a5ab3d0f0
5
5
  SHA512:
6
- metadata.gz: 10302870e79b4ef00cdbe55d48e38f03e602b5bed0ccd5d2cf3b5176fa4daa171e0a9d77f044ec32c95e9bbbbc19a3c8cac3de951900e17edd05f8647ea2ebaa
7
- data.tar.gz: bc923eca00f2a7bcdd6fff58aeb7738e1b47b176cf9db1eba7c3f83d7e864aba433d582614f6e63896d0cd9e9042c7b50203473c0915f9f6ea549b49c89d3264
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 — append marked section
111
- "#{existing.chomp}\n\n#{marked_content}"
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
- key = controllers.keys.find { |k| k.downcase == controller.downcase } || controller
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 (#{app_controllers.size})", "" ]
61
- app_controllers.keys.sort.each do |name|
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 (#{app_controllers.size})", "" ]
71
- app_controllers.keys.sort.each do |name|
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 (#{app_controllers.size})", "" ]
106
+ lines = [ "# Controllers (#{total})", "" ]
81
107
 
82
108
  # Group sibling controllers that share the same parent and identical structure
83
- grouped = app_controllers.keys.sort.group_by do |name|
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 = app_controllers.keys.sort.map { |c| "- #{c}" }.join("\n")
128
- text_response("# Controllers (#{app_controllers.size})\n\n#{list}")
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 = models.keys.sort.map { |m| "- #{m}" }.join("\n")
43
- text_response("# Available models (#{models.size})\n\n#{model_list}\n\n_Use `model:\"Name\"` for full detail._")
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 (#{models.size})", "" ]
47
- models.keys.sort.each do |name|
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 (#{models.size})", "" ]
61
- models.keys.sort.each do |name|
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 = models.keys.sort.map { |m| "- #{m}" }.join("\n")
75
- text_response("# Available models (#{models.size})\n\n#{model_list}")
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
- controllers = data[:controllers] || []
31
- return text_response("No Stimulus controllers found.") if controllers.empty?
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 = controllers.find { |c| c[:name]&.downcase&.tr("-", "_") == normalized }
38
- return text_response("Controller '#{controller}' not found. Available: #{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
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
- sorted = controllers.sort_by { |c| c[:name]&.to_s || "" }
45
- active = sorted.select { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
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 (#{controllers.size})", "" ]
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
- sorted = controllers.sort_by { |c| c[:name]&.to_s || "" }
63
- active = sorted.select { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
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 (#{controllers.size})", "" ]
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 (#{controllers.size})", "" ]
100
+ lines = [ "# Stimulus Controllers (#{total})", "" ]
81
101
  lines << "_HTML naming: `data-controller=\"my-name\"` (dashes in HTML, underscores in filenames)_" << ""
82
- controllers.sort_by { |c| c[:name]&.to_s || "" }.each do |ctrl|
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 << "- #{File.basename(name)} (#{meta[:lines]} lines)#{parts}#{stim}"
73
+ lines << "- #{name} (#{meta[:lines]} lines)#{parts}#{stim}"
74
74
  end
75
75
  ctrl_partials.sort.each do |name, meta|
76
- lines << "- #{File.basename(name)} (#{meta[:lines]} 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 << "- #{File.basename(name)} (#{meta[:lines]} lines)#{parts}#{stim}"
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 << "- #{File.basename(name)} (#{meta[:lines]} lines)#{fields}#{helpers}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "0.15.3"
4
+ VERSION = "0.15.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.3
4
+ version: 0.15.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine