and_one 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ee084901a4915dba0065a11cf14a6996f277d6ca5fba2efd388b27ddc2f349a
4
- data.tar.gz: 05d02c49d473359e70ba9b300711e6d15f52d391ab48e22ca1ddb8651e3eb7bc
3
+ metadata.gz: f74cbffeb954bdf743f6028d005244aa5d4f76c229f7543145d3e77f2b468398
4
+ data.tar.gz: 418273820bcdb2b8a86a3d8b76922ba97b0540bd48b6a6ac5c3e3317fe3ac703
5
5
  SHA512:
6
- metadata.gz: 5f59a82aac0fc38ef5f9d2d11b633a1b881378124834d8d8ec0dd4ecb85e887d16aec5feee30225205df60ab90cb1577eb02ebca0306685736231faad0e53799
7
- data.tar.gz: '0663708679e397440e6e1fe26bee567e868e1909e1138cfb94d3da085cabdc4da8808005f72a610aa6f99fde5f262ffead6ff57bd4bc21e0b685c9c07195df0b'
6
+ metadata.gz: 159e5753a847aa025f9168c9962cdb47d53489ccd0b81272b81039f6784be1cd2b3ecc9ec7537a19245b030602f539e7578d52d05dcdd28abcc45101a47d2a44
7
+ data.tar.gz: 9885b04b8ed1b4aded500bb673ac5f4526ea834b96c3beee407a7e3e165fa2a3eea9100edd5d67c4d0c6a78cde3940557b77a2609f283249afd1d6ce7f73b936
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ # Get staged Ruby files
5
+ STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.rb$' || true)
6
+
7
+ if [ -n "$STAGED_FILES" ]; then
8
+ echo "Running RuboCop on staged files..."
9
+ bundle exec rubocop --force-exclusion $STAGED_FILES
10
+ fi
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-03-02
4
+
5
+ ### Added
6
+
7
+ - **Dev toast notifications** — When an N+1 is detected during a request, a toast notification is injected into the bottom-right corner of the page showing which tables were affected with a link to the `/__and_one` dashboard. Enabled by default in development. Auto-dismisses after 8 seconds; hover to keep open. Only appears on HTML 200 responses. Disable with `AndOne.dev_toast = false`.
8
+ - New `dev_toast` configuration option
9
+ - "Development UI" section in README documenting both the toast and dashboard features
10
+
3
11
  ## [0.1.0] - 2026-02-27
4
12
 
5
13
  - Initial release
data/README.md CHANGED
@@ -16,6 +16,7 @@ AndOne stays completely invisible until it detects an N+1 query — then it tell
16
16
  - **Ignore file** — `.and_one_ignore` with `gem:`, `path:`, `query:`, and `fingerprint:` rules
17
17
  - **Aggregate mode** — report each unique N+1 once per server session with occurrence counts
18
18
  - **Test matchers** — Minitest (`assert_no_n_plus_one`) and RSpec (`expect { }.not_to cause_n_plus_one`)
19
+ - **Dev toast notifications** — in-page toast on every page that triggers an N+1, with a link to the dashboard
19
20
  - **Dev UI dashboard** — browse `/__and_one` in development for a live N+1 overview
20
21
  - **Rails console integration** — auto-scans in `rails console` and prints warnings inline
21
22
  - **Structured JSON logging** — JSON output mode for Datadog, Splunk, and other log aggregation services
@@ -148,6 +149,36 @@ AndOne.aggregate.size # number of unique patterns
148
149
  AndOne.aggregate.reset! # clear and start fresh
149
150
  ```
150
151
 
152
+ ## Development UI
153
+
154
+ ### In-page toast notifications
155
+
156
+ When an N+1 is detected during a request, AndOne injects a small toast notification into the bottom-right corner of the page. The toast shows which tables were affected and links to the full dashboard for details.
157
+
158
+ This is enabled by default in development — no configuration needed. The toast auto-dismisses after 8 seconds, but hovering over it keeps it open.
159
+
160
+ To disable it:
161
+
162
+ ```ruby
163
+ # config/initializers/and_one.rb
164
+ AndOne.dev_toast = false
165
+ ```
166
+
167
+ The toast only appears on HTML responses with a 200 status, so it won't interfere with API endpoints, redirects, or error pages.
168
+
169
+ ### Dashboard
170
+
171
+ Browse `/__and_one` in development for a full overview of every unique N+1 detected in the current server session. The dashboard shows the query, origin, fix location, and suggested `.includes()` call for each detection.
172
+
173
+ The dashboard requires aggregate mode to track detections across requests:
174
+
175
+ ```ruby
176
+ # config/initializers/and_one.rb
177
+ AndOne.aggregate_mode = true
178
+ ```
179
+
180
+ Both features work together: the toast gives you immediate feedback on the page you're looking at, and the dashboard link takes you to the full picture.
181
+
151
182
  ## Test Matchers
152
183
 
153
184
  ### Minitest
@@ -217,6 +248,9 @@ AndOne.configure do |config|
217
248
  # Aggregate mode — only report each unique N+1 once per session
218
249
  config.aggregate_mode = true
219
250
 
251
+ # In-page toast notifications (default: true in development)
252
+ config.dev_toast = true
253
+
220
254
  # Path to ignore file (default: Rails.root/.and_one_ignore)
221
255
  config.ignore_file_path = Rails.root.join(".and_one_ignore").to_s
222
256
 
@@ -18,8 +18,8 @@ module AndOne
18
18
 
19
19
  private
20
20
 
21
- def and_one_scan
22
- and_one_wrap { yield }
21
+ def and_one_scan(&)
22
+ and_one_wrap(&)
23
23
  end
24
24
  end
25
25
  end
@@ -14,7 +14,7 @@ module AndOne
14
14
  # AndOne.aggregate.reset!
15
15
  #
16
16
  class Aggregate
17
- Entry = Struct.new(:detection, :occurrences, :first_seen_at, :last_seen_at, keyword_init: true)
17
+ Entry = Struct.new(:detection, :occurrences, :first_seen_at, :last_seen_at)
18
18
 
19
19
  def initialize
20
20
  @mutex = Mutex.new
@@ -65,19 +65,19 @@ module AndOne
65
65
 
66
66
  lines = []
67
67
  lines << ""
68
- lines << "🏀 AndOne Session Summary: #{@entries.size} unique N+1 pattern#{'s' if @entries.size != 1}"
69
- lines << "─" * 60
68
+ lines << "🏀 AndOne Session Summary: #{@entries.size} unique N+1 pattern#{"s" if @entries.size != 1}"
69
+ lines << ("─" * 60)
70
70
 
71
71
  @entries.each_with_index do |(fp, entry), i|
72
72
  det = entry.detection
73
- lines << " #{i + 1}) #{det.table_name || 'unknown'} — #{entry.occurrences} occurrence#{'s' if entry.occurrences != 1}"
73
+ lines << " #{i + 1}) #{det.table_name || "unknown"} — #{entry.occurrences} occurrence#{"s" if entry.occurrences != 1}"
74
74
  lines << " #{det.sample_query[0, 120]}"
75
75
  lines << " origin: #{det.origin_frame}" if det.origin_frame
76
76
  lines << " fingerprint: #{fp}"
77
77
  lines << ""
78
78
  end
79
79
 
80
- lines << "─" * 60
80
+ lines << ("─" * 60)
81
81
  lines.join("\n")
82
82
  end
83
83
  end
@@ -50,7 +50,7 @@ module AndOne
50
50
 
51
51
  model = ActiveRecord::Base.descendants.detect do |klass|
52
52
  klass.table_name == table_name
53
- rescue
53
+ rescue StandardError
54
54
  false
55
55
  end
56
56
 
@@ -82,11 +82,11 @@ module AndOne
82
82
 
83
83
  klass.reflect_on_all_associations.each do |assoc|
84
84
  matched = if effective_key
85
- association_matches?(assoc, target_model, effective_key)
86
- else
87
- # For through associations, foreign key may not be directly visible
88
- through_association_matches?(assoc, target_model)
89
- end
85
+ association_matches?(assoc, target_model, effective_key)
86
+ else
87
+ # For through associations, foreign key may not be directly visible
88
+ through_association_matches?(assoc, target_model)
89
+ end
90
90
 
91
91
  next unless matched
92
92
 
@@ -98,10 +98,10 @@ module AndOne
98
98
  fix_hint: build_fix_hint(klass, assoc.name),
99
99
  loading_strategy: strategy,
100
100
  is_through: assoc.is_a?(ActiveRecord::Reflection::ThroughReflection),
101
- is_polymorphic: assoc.respond_to?(:options) && !!assoc.options[:as]
101
+ is_polymorphic: assoc.respond_to?(:options) && !assoc.options[:as].nil?
102
102
  }
103
103
  end
104
- rescue
104
+ rescue StandardError
105
105
  next
106
106
  end
107
107
 
@@ -133,27 +133,25 @@ module AndOne
133
133
  end
134
134
 
135
135
  def association_matches?(assoc, target_model, foreign_key)
136
- begin
137
- case assoc
138
- when ActiveRecord::Reflection::ThroughReflection
139
- # has_many :through — check if the source association points to our target
140
- assoc.klass == target_model
141
- when ActiveRecord::Reflection::HasManyReflection,
142
- ActiveRecord::Reflection::HasOneReflection
143
- if assoc.options[:as]
144
- # Polymorphic: has_many :comments, as: :commentable
145
- # The foreign key is like "commentable_id" and there's a "commentable_type" column
146
- poly_fk = "#{assoc.options[:as]}_id"
147
- assoc.klass == target_model && poly_fk == foreign_key
148
- else
149
- assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key
150
- end
136
+ case assoc
137
+ when ActiveRecord::Reflection::ThroughReflection
138
+ # has_many :through — check if the source association points to our target
139
+ assoc.klass == target_model
140
+ when ActiveRecord::Reflection::HasManyReflection,
141
+ ActiveRecord::Reflection::HasOneReflection
142
+ if assoc.options[:as]
143
+ # Polymorphic: has_many :comments, as: :commentable
144
+ # The foreign key is like "commentable_id" and there's a "commentable_type" column
145
+ poly_fk = "#{assoc.options[:as]}_id"
146
+ assoc.klass == target_model && poly_fk == foreign_key
151
147
  else
152
- false
148
+ assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key
153
149
  end
154
- rescue NameError
150
+ else
155
151
  false
156
152
  end
153
+ rescue NameError
154
+ false
157
155
  end
158
156
 
159
157
  def build_fix_hint(parent_model, association_name)
@@ -161,13 +159,10 @@ module AndOne
161
159
  end
162
160
 
163
161
  # Determine the optimal loading strategy based on query patterns
164
- def loading_strategy(sql, association_name)
162
+ def loading_strategy(sql, _association_name)
165
163
  # If the query has WHERE conditions on the association table, eager_load
166
164
  # is better because it does a LEFT OUTER JOIN allowing WHERE filtering
167
- if sql =~ /\bWHERE\b/i && sql =~ /\bJOIN\b/i
168
- :eager_load
169
- elsif sql =~ /\bWHERE\b.*\b(?:AND|OR)\b/i
170
- # Complex WHERE — eager_load with JOIN is more efficient
165
+ if sql =~ /\bWHERE\b/i && (sql =~ /\bJOIN\b/i || sql =~ /\b(?:AND|OR)\b/i)
171
166
  :eager_load
172
167
  else
173
168
  # Default: preload is generally faster (separate queries, no JOIN overhead)
@@ -202,10 +197,10 @@ module AndOne
202
197
  return nil unless actionable? && @parent_model
203
198
 
204
199
  assoc_type = if @is_through
205
- "has_many :#{@association_name}, through: ..."
206
- else
207
- "has_many :#{@association_name}"
208
- end
200
+ "has_many :#{@association_name}, through: ..."
201
+ else
202
+ "has_many :#{@association_name}"
203
+ end
209
204
 
210
205
  "Or prevent at the model level: `#{assoc_type}, strict_loading: true` in #{@parent_model.name}"
211
206
  end
@@ -219,8 +214,6 @@ module AndOne
219
214
  "Consider `.eager_load(:#{@association_name})` instead — your query filters on the association, so a JOIN is more efficient"
220
215
  when :preload
221
216
  "Consider `.preload(:#{@association_name})` — separate queries avoid JOIN overhead for simple loading"
222
- else
223
- nil # :includes is the default, no extra hint needed
224
217
  end
225
218
  end
226
219
  end
@@ -49,7 +49,7 @@ module AndOne
49
49
 
50
50
  def finish_scan
51
51
  AndOne.finish if AndOne.scanning?
52
- rescue
52
+ rescue StandardError
53
53
  # Don't let cleanup errors interrupt the console
54
54
  nil
55
55
  end
@@ -79,9 +79,9 @@ module AndOne
79
79
 
80
80
  # IRB in Rails 7.1+ uses IRB::Context#evaluate with hooks
81
81
  # We hook into the SIGINT-safe eval output via an around_eval approach
82
- if defined?(::IRB::Context)
83
- ::IRB::Context.prepend(IrbContextPatch)
84
- end
82
+ return unless defined?(::IRB::Context)
83
+
84
+ ::IRB::Context.prepend(IrbContextPatch)
85
85
  end
86
86
 
87
87
  def remove_irb_hook
@@ -94,15 +94,13 @@ module AndOne
94
94
 
95
95
  @pry_hook_installed = true
96
96
 
97
- ::Pry.hooks.add_hook(:after_eval, :and_one_console) do |result, _pry|
97
+ ::Pry.hooks.add_hook(:after_eval, :and_one_console) do |_result, _pry|
98
98
  AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
99
99
  end
100
100
  end
101
101
 
102
102
  def remove_pry_hook
103
- if defined?(::Pry) && ::Pry.hooks
104
- ::Pry.hooks.delete_hook(:after_eval, :and_one_console)
105
- end
103
+ ::Pry.hooks.delete_hook(:after_eval, :and_one_console) if defined?(::Pry) && ::Pry.hooks
106
104
  @pry_hook_installed = false
107
105
  end
108
106
 
@@ -119,7 +117,7 @@ module AndOne
119
117
  result = super
120
118
  AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
121
119
  result
122
- rescue => e
120
+ rescue StandardError
123
121
  AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
124
122
  raise
125
123
  end
@@ -55,9 +55,9 @@ module AndOne
55
55
  private
56
56
 
57
57
  def extract_table_name(sql)
58
- if sql =~ /\bFROM\s+["`]?(\w+)["`]?/i
59
- $1
60
- end
58
+ return unless sql =~ /\bFROM\s+["`]?(\w+)["`]?/i
59
+
60
+ ::Regexp.last_match(1)
61
61
  end
62
62
 
63
63
  def find_origin_frame
@@ -5,8 +5,8 @@ module AndOne
5
5
  # Each instance tracks queries for a single request/block scope.
6
6
  class Detector
7
7
  DEFAULT_ALLOW_LIST = [
8
- /active_record\/relation.*preload_associations/,
9
- /active_record\/validations\/uniqueness/
8
+ %r{active_record/relation.*preload_associations},
9
+ %r{active_record/validations/uniqueness}
10
10
  ].freeze
11
11
 
12
12
  attr_reader :detections
@@ -58,7 +58,7 @@ module AndOne
58
58
  name = payload[:name]
59
59
 
60
60
  next if name == "SCHEMA"
61
- next if !sql.include?("SELECT")
61
+ next unless sql.include?("SELECT")
62
62
  next if payload[:cached]
63
63
  next if current_detector.send(:ignored?, sql)
64
64
 
@@ -79,13 +79,13 @@ module AndOne
79
79
  @query_holder[location_key] << sql
80
80
 
81
81
  # Only store caller on the second occurrence to save memory
82
- if @query_counter[location_key] >= 2
83
- @query_callers[location_key] = locations
84
- @query_metadata[location_key] ||= {
85
- connection_adapter: adapter_name,
86
- type_casted_binds: payload[:type_casted_binds]
87
- }
88
- end
82
+ return unless @query_counter[location_key] >= 2
83
+
84
+ @query_callers[location_key] = locations
85
+ @query_metadata[location_key] ||= {
86
+ connection_adapter: adapter_name,
87
+ type_casted_binds: payload[:type_casted_binds]
88
+ }
89
89
  end
90
90
 
91
91
  def location_fingerprint(locations)
@@ -99,7 +99,7 @@ module AndOne
99
99
 
100
100
  def adapter_name
101
101
  ActiveRecord::Base.connection_db_config.adapter
102
- rescue
102
+ rescue StandardError
103
103
  "unknown"
104
104
  end
105
105
 
@@ -135,7 +135,7 @@ module AndOne
135
135
  end
136
136
 
137
137
  def ignored?(sql)
138
- @ignore_queries.any? { |pattern| pattern === sql }
138
+ @ignore_queries.any? { |pattern| pattern.match?(sql) }
139
139
  end
140
140
  end
141
141
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AndOne
4
+ # Injects a small toast notification into HTML responses when N+1 queries
5
+ # are detected during the request. Links to the DevUI dashboard for details.
6
+ #
7
+ # Activated automatically by the Railtie in development when `dev_toast` is on,
8
+ # or can be enabled manually:
9
+ # AndOne.dev_toast = true
10
+ #
11
+ # The toast appears as a fixed-position badge in the bottom-right corner
12
+ # and auto-dismisses after 8 seconds (click to keep open).
13
+ module DevToast
14
+ module_function
15
+
16
+ # Injects toast HTML/JS/CSS before </body> in an HTML response.
17
+ # Returns the modified body string, or the original if not injectable.
18
+ def inject(body_string, detections)
19
+ return body_string if detections.nil? || detections.empty?
20
+ return body_string unless body_string.include?("</body>")
21
+
22
+ toast_html = render_toast(detections)
23
+ body_string.sub("</body>", "#{toast_html}\n</body>")
24
+ end
25
+
26
+ def render_toast(detections)
27
+ count = detections.size
28
+ label = "N+1 quer#{count == 1 ? "y" : "ies"}"
29
+
30
+ summaries = detections.map do |d|
31
+ table = escape(d.table_name || "unknown")
32
+ "#{d.count}x <code>#{table}</code>"
33
+ end.first(5)
34
+
35
+ extra = count > 5 ? "<div class=\"and-one-toast-extra\">...and #{count - 5} more</div>" : ""
36
+
37
+ <<~HTML
38
+ <div id="and-one-toast" class="and-one-toast" role="status" aria-live="polite">
39
+ <div class="and-one-toast-header">
40
+ <span class="and-one-toast-icon">🏀</span>
41
+ <strong>AndOne:</strong> #{count} #{label} detected
42
+ <button class="and-one-toast-close" onclick="document.getElementById('and-one-toast').remove()" aria-label="Dismiss">&times;</button>
43
+ </div>
44
+ <div class="and-one-toast-body">
45
+ #{summaries.join("<br>")}
46
+ #{extra}
47
+ </div>
48
+ <a class="and-one-toast-link" href="#{DevUI::MOUNT_PATH}">View Dashboard →</a>
49
+ </div>
50
+ <style>
51
+ .and-one-toast {
52
+ position: fixed;
53
+ bottom: 1rem;
54
+ right: 1rem;
55
+ z-index: 999999;
56
+ background: #1a1a2e;
57
+ color: #e0e0e0;
58
+ border: 2px solid #ff6b6b;
59
+ border-radius: 8px;
60
+ padding: 0;
61
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
62
+ font-size: 13px;
63
+ max-width: 380px;
64
+ min-width: 260px;
65
+ box-shadow: 0 4px 24px rgba(0,0,0,0.4);
66
+ animation: and-one-slide-in 0.3s ease-out;
67
+ transition: opacity 0.3s ease;
68
+ }
69
+ .and-one-toast-header {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 0.4rem;
73
+ padding: 0.5rem 0.75rem;
74
+ background: #16213e;
75
+ border-radius: 6px 6px 0 0;
76
+ border-bottom: 1px solid #2a2a4a;
77
+ color: #ff6b6b;
78
+ font-size: 13px;
79
+ }
80
+ .and-one-toast-icon { font-size: 16px; }
81
+ .and-one-toast-close {
82
+ margin-left: auto;
83
+ background: none;
84
+ border: none;
85
+ color: #888;
86
+ font-size: 18px;
87
+ cursor: pointer;
88
+ padding: 0 0.25rem;
89
+ line-height: 1;
90
+ }
91
+ .and-one-toast-close:hover { color: #ff6b6b; }
92
+ .and-one-toast-body {
93
+ padding: 0.5rem 0.75rem;
94
+ line-height: 1.5;
95
+ color: #ccc;
96
+ }
97
+ .and-one-toast-body code {
98
+ background: #16213e;
99
+ padding: 0.1rem 0.3rem;
100
+ border-radius: 3px;
101
+ font-size: 12px;
102
+ color: #ffd93d;
103
+ }
104
+ .and-one-toast-extra {
105
+ color: #888;
106
+ font-size: 12px;
107
+ margin-top: 0.25rem;
108
+ }
109
+ .and-one-toast-link {
110
+ display: block;
111
+ padding: 0.5rem 0.75rem;
112
+ color: #a8d8ea;
113
+ text-decoration: none;
114
+ font-size: 12px;
115
+ border-top: 1px solid #2a2a4a;
116
+ }
117
+ .and-one-toast-link:hover {
118
+ background: #16213e;
119
+ border-radius: 0 0 6px 6px;
120
+ text-decoration: underline;
121
+ }
122
+ @keyframes and-one-slide-in {
123
+ from { transform: translateY(1rem); opacity: 0; }
124
+ to { transform: translateY(0); opacity: 1; }
125
+ }
126
+ </style>
127
+ <script>
128
+ (function() {
129
+ var toast = document.getElementById('and-one-toast');
130
+ if (!toast) return;
131
+ var timer = setTimeout(function() {
132
+ toast.style.opacity = '0';
133
+ setTimeout(function() { toast.remove(); }, 300);
134
+ }, 8000);
135
+ toast.addEventListener('mouseenter', function() { clearTimeout(timer); });
136
+ })();
137
+ </script>
138
+ HTML
139
+ end
140
+
141
+ def escape(text)
142
+ text.to_s
143
+ .gsub("&", "&amp;")
144
+ .gsub("<", "&lt;")
145
+ .gsub(">", "&gt;")
146
+ .gsub('"', "&quot;")
147
+ end
148
+ end
149
+ end
@@ -28,7 +28,7 @@ module AndOne
28
28
 
29
29
  private
30
30
 
31
- def serve_dashboard(env)
31
+ def serve_dashboard(_env)
32
32
  entries = AndOne.aggregate_mode ? AndOne.aggregate.detections : {}
33
33
 
34
34
  html = render_html(entries)
@@ -37,49 +37,49 @@ module AndOne
37
37
 
38
38
  def render_html(entries)
39
39
  rows = if entries.empty?
40
- <<~HTML
41
- <tr>
42
- <td colspan="6" class="empty">
43
- No N+1 queries detected yet.
44
- #{AndOne.aggregate_mode ? "" : "<br><strong>Tip:</strong> Set <code>AndOne.aggregate_mode = true</code> to collect detections across requests."}
45
- </td>
46
- </tr>
47
- HTML
48
- else
49
- entries.map.with_index do |(fp, entry), i|
50
- det = entry.detection
51
- suggestion = begin
52
- AndOne::AssociationResolver.resolve(det, det.raw_caller_strings)
53
- rescue
54
- nil
55
- end
56
- fix = suggestion&.actionable? ? h(suggestion.fix_hint) : "—"
57
- strict_hint = suggestion&.strict_loading_hint ? h(suggestion.strict_loading_hint) : ""
58
- loading_hint = suggestion&.loading_strategy_hint ? h(suggestion.loading_strategy_hint) : ""
40
+ <<~HTML
41
+ <tr>
42
+ <td colspan="6" class="empty">
43
+ No N+1 queries detected yet.
44
+ #{"<br><strong>Tip:</strong> Set <code>AndOne.aggregate_mode = true</code> to collect detections across requests." unless AndOne.aggregate_mode}
45
+ </td>
46
+ </tr>
47
+ HTML
48
+ else
49
+ entries.map.with_index do |(fp, entry), i|
50
+ det = entry.detection
51
+ suggestion = begin
52
+ AndOne::AssociationResolver.resolve(det, det.raw_caller_strings)
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ fix = suggestion&.actionable? ? h(suggestion.fix_hint) : "—"
57
+ strict_hint = suggestion&.strict_loading_hint ? h(suggestion.strict_loading_hint) : ""
58
+ loading_hint = suggestion&.loading_strategy_hint ? h(suggestion.loading_strategy_hint) : ""
59
59
 
60
- origin = det.origin_frame ? format_frame(det.origin_frame) : "—"
61
- fix_loc = det.fix_location ? format_frame(det.fix_location) : "—"
60
+ origin = det.origin_frame ? format_frame(det.origin_frame) : "—"
61
+ fix_loc = det.fix_location ? format_frame(det.fix_location) : "—"
62
62
 
63
- <<~HTML
64
- <tr>
65
- <td>#{i + 1}</td>
66
- <td><code>#{h(det.table_name || 'unknown')}</code></td>
67
- <td>#{entry.occurrences}</td>
68
- <td><code class="sql">#{h(truncate(det.sample_query, 200))}</code></td>
69
- <td>
70
- <div class="origin">#{h(origin)}</div>
71
- <div class="fix-loc">⇒ #{h(fix_loc)}</div>
72
- </td>
73
- <td>
74
- <div class="suggestion">#{fix}</div>
75
- #{"<div class=\"strategy\">#{loading_hint}</div>" unless loading_hint.empty?}
76
- #{"<div class=\"strict\">#{strict_hint}</div>" unless strict_hint.empty?}
77
- <div class="fingerprint">#{h(fp)}</div>
78
- </td>
79
- </tr>
80
- HTML
81
- end.join
82
- end
63
+ <<~HTML
64
+ <tr>
65
+ <td>#{i + 1}</td>
66
+ <td><code>#{h(det.table_name || "unknown")}</code></td>
67
+ <td>#{entry.occurrences}</td>
68
+ <td><code class="sql">#{h(truncate(det.sample_query, 200))}</code></td>
69
+ <td>
70
+ <div class="origin">#{h(origin)}</div>
71
+ <div class="fix-loc">⇒ #{h(fix_loc)}</div>
72
+ </td>
73
+ <td>
74
+ <div class="suggestion">#{fix}</div>
75
+ #{"<div class=\"strategy\">#{loading_hint}</div>" unless loading_hint.empty?}
76
+ #{"<div class=\"strict\">#{strict_hint}</div>" unless strict_hint.empty?}
77
+ <div class="fingerprint">#{h(fp)}</div>
78
+ </td>
79
+ </tr>
80
+ HTML
81
+ end.join
82
+ end
83
83
 
84
84
  <<~HTML
85
85
  <!DOCTYPE html>
@@ -114,7 +114,7 @@ module AndOne
114
114
  </head>
115
115
  <body>
116
116
  <h1>🏀 AndOne — N+1 Dashboard</h1>
117
- <p class="subtitle">#{entries.size} unique N+1 pattern#{'s' if entries.size != 1} detected this session</p>
117
+ <p class="subtitle">#{entries.size} unique N+1 pattern#{"s" if entries.size != 1} detected this session</p>
118
118
  <div class="actions">
119
119
  <a href="#{MOUNT_PATH}">↻ Refresh</a>
120
120
  </div>
@@ -140,14 +140,15 @@ module AndOne
140
140
 
141
141
  def h(text)
142
142
  text.to_s
143
- .gsub("&", "&amp;")
144
- .gsub("<", "&lt;")
145
- .gsub(">", "&gt;")
146
- .gsub('"', "&quot;")
143
+ .gsub("&", "&amp;")
144
+ .gsub("<", "&lt;")
145
+ .gsub(">", "&gt;")
146
+ .gsub('"', "&quot;")
147
147
  end
148
148
 
149
149
  def truncate(text, max)
150
150
  return text if text.length <= max
151
+
151
152
  "#{text[0...max]}..."
152
153
  end
153
154
 
@@ -22,7 +22,7 @@ module AndOne
22
22
  normalized.gsub!(/`(\w+)`/, '\1')
23
23
 
24
24
  # Normalize single-quoted string literals
25
- normalized.gsub!(/\\[']/, "")
25
+ normalized.gsub!("\\'", "")
26
26
  normalized.gsub!(/'(?:[^'\\]|\\.)*'/m, "?")
27
27
 
28
28
  # Normalize numbers (standalone, not part of identifiers)
@@ -23,7 +23,8 @@ module AndOne
23
23
  parts = []
24
24
  parts << ""
25
25
  parts << colorize(SEPARATOR, :red)
26
- parts << colorize(" 🏀 And One! #{detections.size} N+1 quer#{detections.size == 1 ? 'y' : 'ies'} detected", :red, :bold)
26
+ parts << colorize(" 🏀 And One! #{detections.size} N+1 quer#{detections.size == 1 ? "y" : "ies"} detected", :red,
27
+ :bold)
27
28
  parts << colorize(SEPARATOR, :red)
28
29
 
29
30
  detections.each_with_index do |detection, i|
@@ -38,12 +39,13 @@ module AndOne
38
39
 
39
40
  private
40
41
 
41
- def format_detection(detection, index)
42
+ def format_detection(detection, index) # rubocop:disable Metrics
42
43
  lines = []
43
44
  cleaned_bt = clean_backtrace(detection.raw_caller_strings)
44
45
 
45
46
  # Header with count and fingerprint
46
- lines << colorize(" #{index}) #{detection.count}x repeated query on `#{detection.table_name || 'unknown'}`", :yellow, :bold)
47
+ lines << colorize(" #{index}) #{detection.count}x repeated query on `#{detection.table_name || "unknown"}`",
48
+ :yellow, :bold)
47
49
  lines << colorize(" fingerprint: #{detection.fingerprint}", :dim)
48
50
  lines << ""
49
51
 
@@ -68,9 +70,8 @@ module AndOne
68
70
 
69
71
  # Abbreviated call stack
70
72
  lines << colorize(" Call stack:", :cyan)
71
- cleaned_bt.first(6).each_with_index do |frame, fi|
72
- prefix = fi == 0 ? " " : " "
73
- lines << colorize("#{prefix}#{frame}", :dim)
73
+ cleaned_bt.first(6).each_with_index do |frame, _fi|
74
+ lines << colorize(" #{frame}", :dim)
74
75
  end
75
76
  lines << colorize(" ... (#{cleaned_bt.size - 6} more frames)", :dim) if cleaned_bt.size > 6
76
77
  lines << ""
@@ -80,12 +81,8 @@ module AndOne
80
81
  if suggestion&.actionable?
81
82
  lines << colorize(" 💡 Suggestion:", :cyan, :bold)
82
83
  lines << colorize(" #{suggestion.fix_hint}", :green)
83
- if suggestion.loading_strategy_hint
84
- lines << colorize(" #{suggestion.loading_strategy_hint}", :green)
85
- end
86
- if suggestion.strict_loading_hint
87
- lines << colorize(" #{suggestion.strict_loading_hint}", :dim)
88
- end
84
+ lines << colorize(" #{suggestion.loading_strategy_hint}", :green) if suggestion.loading_strategy_hint
85
+ lines << colorize(" #{suggestion.strict_loading_hint}", :dim) if suggestion.strict_loading_hint
89
86
  end
90
87
 
91
88
  # Ignore hint
@@ -98,7 +95,7 @@ module AndOne
98
95
 
99
96
  def resolve_suggestion(detection, cleaned_backtrace)
100
97
  AssociationResolver.resolve(detection, cleaned_backtrace)
101
- rescue
98
+ rescue StandardError
102
99
  nil
103
100
  end
104
101
 
@@ -121,6 +118,7 @@ module AndOne
121
118
 
122
119
  def truncate_query(sql, max_length: 200)
123
120
  return sql if sql.length <= max_length
121
+
124
122
  "#{sql[0...max_length]}..."
125
123
  end
126
124
 
@@ -23,7 +23,7 @@ module AndOne
23
23
  # fingerprint:abc123def456
24
24
  #
25
25
  class IgnoreFile
26
- Rule = Struct.new(:type, :pattern, keyword_init: true)
26
+ Rule = Struct.new(:type, :pattern)
27
27
 
28
28
  attr_reader :rules
29
29
 
@@ -90,8 +90,8 @@ module AndOne
90
90
  def matches_path?(glob, raw_caller_strings)
91
91
  # Convert glob to regex: * -> [^/]*, ** -> .*
92
92
  regex_str = Regexp.escape(glob)
93
- .gsub('\*\*', '.*')
94
- .gsub('\*', '[^/]*')
93
+ .gsub('\*\*', ".*")
94
+ .gsub('\*', "[^/]*")
95
95
  regex = Regexp.new(regex_str)
96
96
  raw_caller_strings.any? { |frame| frame.match?(regex) }
97
97
  end
@@ -69,7 +69,7 @@ module AndOne
69
69
 
70
70
  def resolve_suggestion(detection)
71
71
  AssociationResolver.resolve(detection, detection.raw_caller_strings)
72
- rescue
72
+ rescue StandardError
73
73
  nil
74
74
  end
75
75
 
@@ -15,25 +15,25 @@ module AndOne
15
15
  #
16
16
  module MinitestHelper
17
17
  # Assert that the block does NOT trigger any N+1 queries.
18
- def assert_no_n_plus_one(message = nil, &block)
19
- detections = scan_for_n_plus_ones(&block)
20
-
21
- if detections.any?
22
- formatter = Formatter.new(
23
- backtrace_cleaner: AndOne.backtrace_cleaner || AndOne.send(:default_backtrace_cleaner)
24
- )
25
- detail = formatter.format(detections)
26
- summary = detections.map { |d|
27
- "#{d.count} queries to `#{d.table_name || 'unknown'}` (expected 1)"
28
- }.join("; ")
29
- msg = message || "Expected no N+1 queries, but #{detections.size} detected: #{summary}\n#{detail}"
30
- flunk(msg)
31
- end
18
+ def assert_no_n_plus_one(message = nil, &)
19
+ detections = scan_for_n_plus_ones(&)
20
+
21
+ return unless detections.any?
22
+
23
+ formatter = Formatter.new(
24
+ backtrace_cleaner: AndOne.backtrace_cleaner || AndOne.send(:default_backtrace_cleaner)
25
+ )
26
+ detail = formatter.format(detections)
27
+ summary = detections.map do |d|
28
+ "#{d.count} queries to `#{d.table_name || "unknown"}` (expected 1)"
29
+ end.join("; ")
30
+ msg = message || "Expected no N+1 queries, but #{detections.size} detected: #{summary}\n#{detail}"
31
+ flunk(msg)
32
32
  end
33
33
 
34
34
  # Assert that the block DOES trigger N+1 queries (useful for documenting known issues).
35
- def assert_n_plus_one(message = nil, &block)
36
- detections = scan_for_n_plus_ones(&block)
35
+ def assert_n_plus_one(message = nil, &)
36
+ detections = scan_for_n_plus_ones(&)
37
37
 
38
38
  if detections.empty?
39
39
  msg = message || "Expected N+1 queries, but none were detected"
@@ -45,7 +45,7 @@ module AndOne
45
45
 
46
46
  private
47
47
 
48
- def scan_for_n_plus_ones(&block)
48
+ def scan_for_n_plus_ones(&)
49
49
  # Temporarily disable raise_on_detect so scan returns detections
50
50
  # instead of raising
51
51
  previous_raise = AndOne.raise_on_detect
@@ -54,7 +54,7 @@ module AndOne
54
54
  AndOne.notifications_callback = nil
55
55
 
56
56
  begin
57
- AndOne.scan(&block) || []
57
+ AndOne.scan(&) || []
58
58
  ensure
59
59
  AndOne.raise_on_detect = previous_raise
60
60
  AndOne.notifications_callback = previous_callback
@@ -100,9 +100,9 @@ module AndOne
100
100
  backtrace_cleaner: AndOne.backtrace_cleaner || AndOne.send(:default_backtrace_cleaner)
101
101
  )
102
102
  detail = formatter.format(@detections)
103
- summary = @detections.map { |d|
104
- "#{d.count} queries to `#{d.table_name || 'unknown'}` (expected 1)"
105
- }.join("; ")
103
+ summary = @detections.map do |d|
104
+ "#{d.count} queries to `#{d.table_name || "unknown"}` (expected 1)"
105
+ end.join("; ")
106
106
  "expected no N+1 queries, but #{@detections.size} detected: #{summary}\n#{detail}"
107
107
  end
108
108
 
@@ -5,6 +5,10 @@ module AndOne
5
5
  # Designed to NOT interfere with error propagation —
6
6
  # if the app raises, we cleanly stop scanning without adding
7
7
  # to or corrupting the original backtrace.
8
+ #
9
+ # When `AndOne.dev_toast` is enabled (default in development),
10
+ # detected N+1s are injected as a toast notification into HTML responses
11
+ # with a link to the DevUI dashboard.
8
12
  class Middleware
9
13
  include ScanHelper
10
14
 
@@ -13,7 +17,41 @@ module AndOne
13
17
  end
14
18
 
15
19
  def call(env)
16
- and_one_wrap { @app.call(env) }
20
+ return @app.call(env) if !AndOne.enabled? || AndOne.scanning?
21
+
22
+ begin
23
+ AndOne.scan
24
+ status, headers, body = @app.call(env)
25
+ detections = AndOne.finish
26
+
27
+ if AndOne.dev_toast && detections&.any? && html_response?(headers) && status == 200
28
+ body = inject_toast(body, detections)
29
+ # Recalculate Content-Length since we modified the body
30
+ headers.delete("content-length")
31
+ headers.delete("Content-Length")
32
+ end
33
+
34
+ [status, headers, body]
35
+ rescue Exception # rubocop:disable Lint/RescueException
36
+ and_one_quietly_stop
37
+ raise
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def html_response?(headers)
44
+ content_type = headers["content-type"] || headers["Content-Type"]
45
+ content_type&.include?("text/html")
46
+ end
47
+
48
+ def inject_toast(body, detections)
49
+ full_body = +""
50
+ body.each { |chunk| full_body << chunk }
51
+ body.close if body.respond_to?(:close)
52
+
53
+ injected = DevToast.inject(full_body, detections)
54
+ [injected]
17
55
  end
18
56
  end
19
57
  end
@@ -13,9 +13,12 @@ module AndOne
13
13
  # Rack middleware for web requests
14
14
  app.middleware.insert_before(0, AndOne::Middleware)
15
15
 
16
- # Dev UI dashboard for N+1 overview (requires aggregate_mode)
17
16
  if Rails.env.development?
17
+ # Dev UI dashboard for N+1 overview (requires aggregate_mode)
18
18
  app.middleware.use(AndOne::DevUI)
19
+
20
+ # Dev toast: show in-page N+1 notifications (default on in development)
21
+ AndOne.dev_toast = true if AndOne.dev_toast.nil?
19
22
  end
20
23
 
21
24
  # ActiveJob hook — covers all job backends (Sidekiq, GoodJob, SolidQueue, etc.)
data/lib/and_one/rspec.rb CHANGED
@@ -13,6 +13,8 @@
13
13
 
14
14
  require "and_one"
15
15
 
16
- RSpec.configure do |config|
17
- config.include AndOne::RSpecHelper
18
- end if defined?(RSpec)
16
+ if defined?(RSpec)
17
+ RSpec.configure do |config|
18
+ config.include AndOne::RSpecHelper
19
+ end
20
+ end
@@ -15,7 +15,7 @@ module AndOne
15
15
  result = yield
16
16
  AndOne.finish
17
17
  result
18
- rescue Exception
18
+ rescue Exception # rubocop:disable Lint/RescueException
19
19
  and_one_quietly_stop
20
20
  raise
21
21
  end
@@ -25,7 +25,7 @@ module AndOne
25
25
  Thread.current[:and_one_detector]&.send(:unsubscribe)
26
26
  Thread.current[:and_one_detector] = nil
27
27
  Thread.current[:and_one_paused] = false
28
- rescue
28
+ rescue StandardError
29
29
  nil
30
30
  end
31
31
  end
@@ -19,8 +19,8 @@ module AndOne
19
19
  class SidekiqMiddleware
20
20
  include ScanHelper
21
21
 
22
- def call(_worker, _msg, _queue)
23
- and_one_wrap { yield }
22
+ def call(_worker, _msg, _queue, &)
23
+ and_one_wrap(&)
24
24
  end
25
25
  end
26
26
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AndOne
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/and_one.rb CHANGED
@@ -15,7 +15,8 @@ module AndOne
15
15
  attr_accessor :enabled, :raise_on_detect, :backtrace_cleaner,
16
16
  :allow_stack_paths, :ignore_queries, :ignore_callers,
17
17
  :min_n_queries, :notifications_callback, :aggregate_mode,
18
- :ignore_file_path, :json_logging, :env_thresholds
18
+ :ignore_file_path, :json_logging, :env_thresholds,
19
+ :dev_toast
19
20
 
20
21
  def configure
21
22
  yield self
@@ -33,19 +34,19 @@ module AndOne
33
34
 
34
35
  start_scan
35
36
 
36
- if block_given?
37
- begin
38
- yield
39
- detections = detector.finish
40
- stop_scan
41
- report(detections) if detections.any?
42
- detections
43
- rescue Exception => e
44
- # On error, clean up without reporting — don't add noise to real errors
45
- detector&.send(:unsubscribe)
46
- stop_scan
47
- raise
48
- end
37
+ return unless block_given?
38
+
39
+ begin
40
+ yield
41
+ detections = detector.finish
42
+ stop_scan
43
+ report(detections) if detections.any?
44
+ detections
45
+ rescue Exception # rubocop:disable Lint/RescueException
46
+ # On error, clean up without reporting — don't add noise to real errors
47
+ detector&.send(:unsubscribe)
48
+ stop_scan
49
+ raise
49
50
  end
50
51
  end
51
52
 
@@ -133,7 +134,7 @@ module AndOne
133
134
  if defined?(Rails) && Rails.respond_to?(:env)
134
135
  Rails.env.to_s
135
136
  else
136
- ENV["RAILS_ENV"] || ENV["RACK_ENV"]
137
+ ENV["RAILS_ENV"] || ENV.fetch("RACK_ENV", nil)
137
138
  end
138
139
  end
139
140
 
@@ -150,7 +151,7 @@ module AndOne
150
151
  Thread.current
151
152
  end
152
153
 
153
- def report(detections)
154
+ def report(detections) # rubocop:disable Metrics
154
155
  # Filter out ignored detections
155
156
  detections = detections.reject do |d|
156
157
  ignore_list.ignored?(d, d.raw_caller_strings) ||
@@ -181,7 +182,7 @@ module AndOne
181
182
  if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
182
183
  Rails.logger.warn(json_output)
183
184
  else
184
- $stderr.puts(json_output)
185
+ warn(json_output)
185
186
  end
186
187
  end
187
188
 
@@ -191,7 +192,7 @@ module AndOne
191
192
  if ENV["GITHUB_ACTIONS"]
192
193
  detections.each do |d|
193
194
  file, line = parse_frame_location(d.fix_location || d.origin_frame)
194
- query_count = "#{d.count} queries to `#{d.table_name || 'unknown'}`"
195
+ query_count = "#{d.count} queries to `#{d.table_name || "unknown"}`"
195
196
  if file
196
197
  $stdout.puts "::warning file=#{file},line=#{line || 1}::N+1 detected: #{query_count}. Add `.includes(:#{suggest_association_name(d)})` to fix."
197
198
  else
@@ -200,15 +201,11 @@ module AndOne
200
201
  end
201
202
  end
202
203
 
203
- if raise_on_detect
204
- raise NPlus1Error, "\n#{message}"
205
- else
206
- unless json_logging
207
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
208
- Rails.logger.warn("\n#{message}")
209
- end
210
- $stderr.puts("\n#{message}") if $stderr.tty?
211
- end
204
+ raise NPlus1Error, "\n#{message}" if raise_on_detect
205
+
206
+ unless json_logging
207
+ Rails.logger.warn("\n#{message}") if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
208
+ warn("\n#{message}") if $stderr.tty?
212
209
  end
213
210
  end
214
211
  end
@@ -222,7 +219,7 @@ module AndOne
222
219
  return false unless patterns&.any?
223
220
 
224
221
  raw_caller_strings.any? do |frame|
225
- patterns.any? { |pattern| pattern === frame }
222
+ patterns.any? { |pattern| pattern.match?(frame) }
226
223
  end
227
224
  end
228
225
 
@@ -231,20 +228,24 @@ module AndOne
231
228
 
232
229
  # Extract file:line from a backtrace frame like "app/controllers/posts_controller.rb:15:in `index'"
233
230
  clean = frame
234
- .sub(%r{.*/app/}, "app/")
235
- .sub(%r{.*/lib/}, "lib/")
236
- .sub(%r{.*/test/}, "test/")
237
- .sub(%r{.*/spec/}, "spec/")
231
+ .sub(%r{.*/app/}, "app/")
232
+ .sub(%r{.*/lib/}, "lib/")
233
+ .sub(%r{.*/test/}, "test/")
234
+ .sub(%r{.*/spec/}, "spec/")
238
235
 
239
236
  if clean =~ /\A(.+?):(\d+)/
240
- [$1, $2.to_i]
237
+ [::Regexp.last_match(1), ::Regexp.last_match(2).to_i]
241
238
  else
242
239
  [clean, nil]
243
240
  end
244
241
  end
245
242
 
246
243
  def suggest_association_name(detection)
247
- suggestion = AssociationResolver.resolve(detection, detection.raw_caller_strings) rescue nil
244
+ suggestion = begin
245
+ AssociationResolver.resolve(detection, detection.raw_caller_strings)
246
+ rescue StandardError
247
+ nil
248
+ end
248
249
  suggestion&.association_name || detection.table_name || "association"
249
250
  end
250
251
 
@@ -271,6 +272,7 @@ require_relative "and_one/aggregate"
271
272
  require_relative "and_one/matchers"
272
273
  require_relative "and_one/scan_helper"
273
274
  require_relative "and_one/dev_ui"
275
+ require_relative "and_one/dev_toast"
274
276
  require_relative "and_one/console"
275
277
  require_relative "and_one/middleware"
276
278
  require_relative "and_one/active_job_hook"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: and_one
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Thompson
@@ -10,7 +10,7 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: railties
13
+ name: activerecord
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - ">="
@@ -24,7 +24,7 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.0'
26
26
  - !ruby/object:Gem::Dependency
27
- name: activerecord
27
+ name: activesupport
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
@@ -38,7 +38,7 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: '7.0'
40
40
  - !ruby/object:Gem::Dependency
41
- name: activesupport
41
+ name: railties
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
@@ -60,6 +60,7 @@ executables: []
60
60
  extensions: []
61
61
  extra_rdoc_files: []
62
62
  files:
63
+ - ".githooks/pre-commit"
63
64
  - CHANGELOG.md
64
65
  - CODE_OF_CONDUCT.md
65
66
  - LICENSE.txt
@@ -73,6 +74,7 @@ files:
73
74
  - lib/and_one/console.rb
74
75
  - lib/and_one/detection.rb
75
76
  - lib/and_one/detector.rb
77
+ - lib/and_one/dev_toast.rb
76
78
  - lib/and_one/dev_ui.rb
77
79
  - lib/and_one/fingerprint.rb
78
80
  - lib/and_one/formatter.rb
@@ -93,6 +95,7 @@ metadata:
93
95
  homepage_uri: https://github.com/keiththomps/and_one
94
96
  source_code_uri: https://github.com/keiththomps/and_one
95
97
  changelog_uri: https://github.com/keiththomps/and_one/blob/main/CHANGELOG.md
98
+ rubygems_mfa_required: 'true'
96
99
  rdoc_options: []
97
100
  require_paths:
98
101
  - lib