bulletproof 0.1.3

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 20e99f97d84e032e719fd33b16b11778667f2abf8d367f69a4201bdc221707ec
4
+ data.tar.gz: cfe6e4fabcbda428d45b3bbf88b403c67f7df6599704d3ac8bb4e45312f853cb
5
+ SHA512:
6
+ metadata.gz: fe3727c8d74667fc57fd6930f65559ec0414965df3f87dc01c11d0e1d90c443d5b4dff692b9a50368f3e0b202cbee2733c632293477fd309f4499c7df2006e6e
7
+ data.tar.gz: d5a2a4d274e110e1ac0104390cfd6f6f3daf31ef97b1c3335829a214c959b5aa71f5df8bd7ce952d5027f001bd62746e9c8da10a9b3b5e8bcbb53ddfcc00a111
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,49 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+
6
+ Style/StringLiterals:
7
+ EnforcedStyle: double_quotes
8
+
9
+ Style/StringLiteralsInInterpolation:
10
+ EnforcedStyle: double_quotes
11
+
12
+ Style/Documentation:
13
+ Enabled: false
14
+
15
+ Style/OneClassPerFile:
16
+ Exclude:
17
+ - "demo/**/*"
18
+ - "spec/**/*"
19
+
20
+ Style/MultilineBlockChain:
21
+ Enabled: false
22
+
23
+ Metrics/BlockLength:
24
+ Exclude:
25
+ - "spec/**/*"
26
+ - "demo/**/*"
27
+
28
+ Metrics/MethodLength:
29
+ Max: 20
30
+ Exclude:
31
+ - "lib/bulletproof/runtime/request_monitor.rb"
32
+
33
+ Metrics/AbcSize:
34
+ Max: 40
35
+
36
+ Metrics/CyclomaticComplexity:
37
+ Max: 12
38
+
39
+ Metrics/PerceivedComplexity:
40
+ Max: 12
41
+
42
+ Layout/LineLength:
43
+ Max: 160
44
+ Exclude:
45
+ - "spec/**/*"
46
+
47
+ Lint/UnreachableLoop:
48
+ Exclude:
49
+ - "spec/**/*"
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-03-17
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 essei0-0
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # Bulletproof
2
+
3
+ Bulletproof detects ActiveRecord memory problems caused by over-eager `includes`.
4
+
5
+ - **Static analysis** — Scans Ruby source files and flags `includes` calls that load unbounded record sets
6
+ - **Runtime monitoring** — Measures actual record counts and GC pressure per request, and warns when thresholds are exceeded
7
+
8
+ ## Installation
9
+
10
+ ```ruby
11
+ # Gemfile
12
+ gem "bulletproof", group: :development
13
+ ```
14
+
15
+ ```sh
16
+ bundle install
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Static Analysis
22
+
23
+ ### CLI
24
+
25
+ ```sh
26
+ # Analyze a directory
27
+ bundle exec bulletproof app/
28
+
29
+ # Analyze a single file
30
+ bundle exec bulletproof app/models/user.rb
31
+
32
+ # Override thresholds
33
+ bundle exec bulletproof app/ --max-includes-depth 3 --max-associations 5
34
+ ```
35
+
36
+ **Output (violations found):**
37
+
38
+ ```
39
+ [WARNING] app/models/post.rb:12 — nesting depth 3 (limit: 2), no record-limiting method (limit / find, etc.) in chain
40
+ [WARNING] app/controllers/users_controller.rb:45 — 4 associations (limit: 3), no record-limiting method in chain
41
+
42
+ 2 violation(s) found.
43
+ ```
44
+
45
+ **Output (clean):**
46
+
47
+ ```
48
+ No violations found.
49
+ ```
50
+
51
+ The exit code is `0` when clean and `1` when violations are found, making it easy to integrate into CI.
52
+
53
+ ### Programmatic usage
54
+
55
+ ```ruby
56
+ report = Bulletproof.analyze("app/")
57
+
58
+ if report.ok?
59
+ puts "No violations found."
60
+ else
61
+ report.violations.each do |v|
62
+ puts "[#{v.severity.upcase}] #{v.file}:#{v.line} — #{v.message}"
63
+ end
64
+ end
65
+ ```
66
+
67
+ ### How detection works
68
+
69
+ A call to `includes` is only flagged when **both** conditions are true:
70
+
71
+ 1. The nesting depth or association count exceeds the configured threshold
72
+ 2. No record-limiting method (`limit`, `find`, `first`, `page`, etc.) appears anywhere in the method chain
73
+
74
+ ```ruby
75
+ # Flagged — unbounded full-table load
76
+ User.includes(posts: { comments: :author }).all
77
+ Post.includes(:user, :comments, :tags, :likes)
78
+
79
+ # Safe — record count is bounded
80
+ User.includes(posts: { comments: :author }).limit(10)
81
+ User.includes(posts: { comments: :author }).page(1).per(20)
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Runtime Monitoring (Rails)
87
+
88
+ ### Setup
89
+
90
+ ```ruby
91
+ # config/initializers/bulletproof.rb
92
+ Bulletproof.configure do |c|
93
+ c.enabled = Rails.env.development?
94
+
95
+ # ---- Thresholds ----------------------------------------------------------
96
+
97
+ # Max records loaded at once per model (default: 1_000)
98
+ c.max_records_per_model = 1_000
99
+
100
+ # Max total records loaded across all models per request (default: 5_000)
101
+ # find_each / in_batches loads are excluded from this count
102
+ c.max_total_records = 5_000
103
+
104
+ # Max GC runs per request (default: nil = disabled)
105
+ # GC frequency varies widely between apps; set explicitly if needed
106
+ # c.max_gc_runs_per_request = 10
107
+
108
+ # ---- Notifiers -----------------------------------------------------------
109
+
110
+ # Log to Rails.logger.warn (default: true)
111
+ c.rails_logger = true
112
+
113
+ # Inject console.warn into HTML responses (default: true)
114
+ # Visible in the browser's Console tab (F12)
115
+ c.console = true
116
+
117
+ # Inject a floating overlay panel into HTML responses (default: false)
118
+ # Visible on the page without opening DevTools
119
+ c.alert = true
120
+
121
+ # Append warnings to a log file (default: nil = disabled)
122
+ # c.log_file = Rails.root.join("log/bulletproof.log").to_s
123
+
124
+ # Custom notifier callable — for Slack, etc. (default: nil = disabled)
125
+ # c.notifier = ->(w) { SlackNotifier.ping(w.message) }
126
+ end
127
+ ```
128
+
129
+ Setting `enabled = true` causes the Railtie to automatically insert the Rack middleware. You do not need to call `config.middleware.use` manually.
130
+
131
+ ### Notifiers
132
+
133
+ | Key | Default | Description |
134
+ |---|---|---|
135
+ | `rails_logger` | `true` | Calls `Rails.logger.warn` for each warning |
136
+ | `console` | `true` | Injects `console.warn` before `</body>`. Visible in the browser Console tab |
137
+ | `alert` | `false` | Injects a floating overlay panel before `</body>`. Dismissible with ✕ |
138
+ | `log_file` | `nil` (disabled) | Appends timestamped warnings to the specified file path |
139
+ | `notifier` | `nil` (disabled) | Callable receiving a `RuntimeWarning`. Use for Slack, webhooks, etc. |
140
+
141
+ ### Warning output example
142
+
143
+ ```
144
+ [Bulletproof] Post: loaded 5,200 records at once (limit: 1,000)
145
+ → app/controllers/posts_controller.rb:15:in 'index'
146
+
147
+ [Bulletproof] Total records loaded in this request: 7,800 (limit: 5,000) [Post: 5,200, Comment: 2,600]
148
+ ```
149
+
150
+ ### Warning types
151
+
152
+ | Type | Condition | Related config key |
153
+ |---|---|---|
154
+ | `:mass_instantiation` | A single model loaded more than `max_records_per_model` records in one batch | `max_records_per_model` |
155
+ | `:high_total_records` | Total records across all models exceeded `max_total_records` (batch loads excluded) | `max_total_records` |
156
+ | `:gc_pressure` | GC ran more than `max_gc_runs_per_request` times during the request | `max_gc_runs_per_request` |
157
+
158
+ ### `find_each` / `in_batches`
159
+
160
+ Batch processing is intentional and memory-safe, so Bulletproof does not warn on it.
161
+
162
+ ```ruby
163
+ # No warning — find_each loads records in bounded batches
164
+ Post.find_each(batch_size: 500) { |post| process(post) }
165
+
166
+ # Warning — all records loaded into memory at once
167
+ Post.all.to_a
168
+ ```
169
+
170
+ ### How it works
171
+
172
+ Bulletproof subscribes to the `instantiation.active_record` ActiveSupport notification for the duration of each request. It accumulates record counts per model and uses `caller_locations` to identify the application code line responsible for each load. Subscription is scoped to the current thread via `Thread.current`, so parallel requests in multi-threaded servers (e.g. Puma) do not interfere with each other.
173
+
174
+ ---
175
+
176
+ ## Configuration reference
177
+
178
+ | Key | Default | Description |
179
+ |---|---|---|
180
+ | `enabled` | `false` | Enable runtime monitoring |
181
+ | `max_includes_depth` | `2` | Static: max `includes` nesting depth |
182
+ | `max_associations` | `3` | Static: max associations per `includes` call |
183
+ | `max_records_per_model` | `1_000` | Runtime: max records loaded at once per model |
184
+ | `max_total_records` | `5_000` | Runtime: max total records per request (batch loads excluded) |
185
+ | `max_gc_runs_per_request` | `nil` (disabled) | Runtime: max GC runs per request |
186
+ | `rails_logger` | `true` | Notifier: output to `Rails.logger.warn` |
187
+ | `console` | `true` | Notifier: inject `console.warn` into HTML |
188
+ | `alert` | `false` | Notifier: inject overlay panel into HTML |
189
+ | `log_file` | `nil` (disabled) | Notifier: append to log file |
190
+ | `notifier` | `nil` (disabled) | Notifier: custom callable receiving `RuntimeWarning` |
191
+
192
+ ---
193
+
194
+ ## Requirements
195
+
196
+ - Ruby 3.0+
197
+ - Rails 6.0+ (runtime monitoring only)
198
+
199
+ ## License
200
+
201
+ [MIT License](LICENSE.txt)
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/exe/bulletproof ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bulletproof"
5
+ require "optparse"
6
+
7
+ options = {}
8
+
9
+ parser = OptionParser.new do |o|
10
+ o.banner = <<~BANNER
11
+ Usage: bulletproof [options] PATH
12
+
13
+ Static analysis for over-eager ActiveRecord includes.
14
+
15
+ Examples:
16
+ bulletproof app/
17
+ bulletproof app/models/user.rb
18
+ bulletproof app/ --max-includes-depth 3 --max-associations 5
19
+ BANNER
20
+
21
+ o.on("--max-includes-depth N", Integer, "Max nesting depth of includes (default: 2)") do |n|
22
+ options[:max_includes_depth] = n
23
+ end
24
+
25
+ o.on("--max-associations N", Integer, "Max number of associations in one includes (default: 3)") do |n|
26
+ options[:max_associations] = n
27
+ end
28
+
29
+ o.on("-v", "--version", "Print version") do
30
+ puts Bulletproof::VERSION
31
+ exit
32
+ end
33
+
34
+ o.on("-h", "--help", "Print this help") do
35
+ puts o
36
+ exit
37
+ end
38
+ end
39
+
40
+ parser.parse!
41
+
42
+ path = ARGV.first
43
+
44
+ if path.nil?
45
+ warn parser.banner
46
+ exit 1
47
+ end
48
+
49
+ unless File.exist?(path)
50
+ warn "bulletproof: #{path}: No such file or directory"
51
+ exit 1
52
+ end
53
+
54
+ Bulletproof.configure do |c|
55
+ c.max_includes_depth = options[:max_includes_depth] if options[:max_includes_depth]
56
+ c.max_associations = options[:max_associations] if options[:max_associations]
57
+ end
58
+
59
+ report = Bulletproof.analyze(path)
60
+
61
+ if report.ok?
62
+ puts "No violations found."
63
+ exit 0
64
+ else
65
+ puts report
66
+ puts
67
+ puts "#{report.violations.size} violation(s) found."
68
+ exit 1
69
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ class Analyzer
5
+ def initialize(config = Bulletproof.config)
6
+ @config = config
7
+ @detectors = [
8
+ Detectors::ExcessiveIncludesDetector.new(config)
9
+ ]
10
+ end
11
+
12
+ # @param path [String] ファイルまたはディレクトリのパス
13
+ # @return [Report]
14
+ def call(path)
15
+ report = Report.new
16
+ ruby_files(path).each do |file|
17
+ source = File.read(file)
18
+ @detectors.each do |detector|
19
+ detector.call(source, file: file).each { |v| report.add_violation(v) }
20
+ end
21
+ end
22
+ report
23
+ end
24
+
25
+ private
26
+
27
+ def ruby_files(path)
28
+ if File.directory?(path)
29
+ Dir.glob(File.join(path, "**", "*.rb"))
30
+ else
31
+ [path]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ class Configuration
5
+ # ---- 静的解析 -----------------------------------------------------------
6
+
7
+ # includes のネスト深さの上限(例: posts: { comments: :author } は深さ2)
8
+ attr_accessor :max_includes_depth
9
+
10
+ # 1つの includes で許容するアソシエーション数の上限
11
+ attr_accessor :max_associations
12
+
13
+ # ---- ランタイム監視 -----------------------------------------------------
14
+
15
+ # ランタイム監視を有効にするか(デフォルト: false)
16
+ # Rails では config/initializers/bulletproof.rb で true に設定して使う
17
+ attr_accessor :enabled
18
+
19
+ # 1モデルあたりのロード件数の上限
20
+ # 超えると :mass_instantiation 警告を出す
21
+ attr_accessor :max_records_per_model
22
+
23
+ # リクエスト全体(全モデル合算)のロード件数の上限
24
+ # 超えると :high_total_records 警告を出す
25
+ attr_accessor :max_total_records
26
+
27
+ # リクエスト中に許容する GC 実行回数の上限
28
+ # 超えると :gc_pressure 警告を出す
29
+ # nil(デフォルト)のとき無効。GC 頻度はアプリや Ruby 設定に依存するため
30
+ # 必要な場合にのみ明示的に設定する(例: 10)
31
+ attr_accessor :max_gc_runs_per_request
32
+
33
+ # Rails.logger.warn に出力するか(デフォルト: true)
34
+ attr_accessor :rails_logger
35
+
36
+ # HTML レスポンスの </body> 直前に console.warn を注入するか(デフォルト: true)
37
+ # ブラウザの開発者ツールの Console タブで確認できる
38
+ attr_accessor :console
39
+
40
+ # HTML レスポンスにオーバーレイパネルを注入するか(デフォルト: false)
41
+ # 開発者ツールを開かなくても画面上で警告を確認できる
42
+ attr_accessor :alert
43
+
44
+ # 警告をファイルに追記するか(デフォルト: nil = 無効)
45
+ # ファイルパスを文字列で指定する
46
+ # c.log_file = Rails.root.join("log/bulletproof.log").to_s
47
+ attr_accessor :log_file
48
+
49
+ # カスタム通知先。Slack 等に飛ばしたいときに設定する(デフォルト: nil = 無効)
50
+ # callable で RuntimeWarning を引数に取る
51
+ # c.notifier = ->(w) { SlackNotifier.ping(w.message) }
52
+ attr_accessor :notifier
53
+
54
+ def initialize
55
+ @enabled = false
56
+ @max_includes_depth = 2
57
+ @max_associations = 3
58
+ @max_records_per_model = 1_000
59
+ @max_total_records = 5_000
60
+ @max_gc_runs_per_request = nil
61
+ @rails_logger = true
62
+ @console = true
63
+ @alert = false
64
+ @log_file = nil
65
+ @notifier = nil
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop-ast"
4
+
5
+ module Bulletproof
6
+ module Detectors
7
+ # Rubyソースを静的解析し、メモリ過剰消費につながるincludes呼び出しを検出する
8
+ #
9
+ # 検出の前提:
10
+ # includesのネスト深さ・アソシエーション数が大きくても、
11
+ # 件数を絞るメソッドがチェーンにあれば安全とみなす。
12
+ #
13
+ # 危険パターン(フラグを立てる):
14
+ # User.includes(posts: { comments: :author }).all # 全件 + 深いネスト
15
+ # User.includes(:a, :b, :c, :d) # 全件 + 多アソシエーション
16
+ #
17
+ # 安全パターン(フラグを立てない):
18
+ # User.includes(posts: { comments: :author }).limit(10) # 件数制限あり
19
+ # User.includes(posts: { comments: :author }).find(1) # 単件取得
20
+ # User.includes(posts: { comments: :author }).first # 単件取得
21
+ # User.includes(:a, :b, :c, :d).page(1).per(20) # ページネーションあり
22
+ LIMITING_METHODS = %i[
23
+ limit find find_by find_by!
24
+ first first! last last! take take!
25
+ page paginate per per_page
26
+ ].freeze
27
+
28
+ class ExcessiveIncludesDetector
29
+ def initialize(config)
30
+ @config = config
31
+ end
32
+
33
+ # @param source [String] Rubyソースコード
34
+ # @param file [String] ファイルパス(表示用)
35
+ # @return [Array<Violation>]
36
+ def call(source, file: "(string)")
37
+ processed = RuboCop::AST::ProcessedSource.new(source, RUBY_VERSION.to_f, file)
38
+ return [] unless processed.valid_syntax?
39
+
40
+ parent_map = build_parent_map(processed.ast)
41
+ violations = []
42
+ collect_includes_nodes(processed.ast).each do |node|
43
+ check(node, violations, file, parent_map)
44
+ end
45
+ violations
46
+ end
47
+
48
+ private
49
+
50
+ # ---- AST 走査 --------------------------------------------------------
51
+
52
+ def collect_includes_nodes(node, result = [])
53
+ return result unless node.is_a?(RuboCop::AST::Node)
54
+
55
+ result << node if includes_call?(node)
56
+ node.each_child_node { |child| collect_includes_nodes(child, result) }
57
+ result
58
+ end
59
+
60
+ # 各ノードの親を記録するマップを構築する
61
+ def build_parent_map(node, parent = nil, map = {}.compare_by_identity)
62
+ return map unless node.is_a?(RuboCop::AST::Node)
63
+
64
+ map[node] = parent
65
+ node.each_child_node { |child| build_parent_map(child, node, map) }
66
+ map
67
+ end
68
+
69
+ # ---- チェーン解析 ----------------------------------------------------
70
+
71
+ # includes ノードから連続する send チェーンの根(最外ノード)を返す
72
+ def chain_root(node, parent_map)
73
+ current = node
74
+ loop do
75
+ parent = parent_map[current]
76
+ break unless parent&.send_type? && parent.receiver.equal?(current)
77
+
78
+ current = parent
79
+ end
80
+ current
81
+ end
82
+
83
+ # send チェーンに含まれる全メソッド名を収集する(チェーン根から下向き)
84
+ def collect_chain_methods(node, methods = [])
85
+ return methods unless node.is_a?(RuboCop::AST::Node) && node.send_type?
86
+
87
+ methods << node.method_name
88
+ collect_chain_methods(node.receiver, methods)
89
+ methods
90
+ end
91
+
92
+ # ---- 検出ロジック ----------------------------------------------------
93
+
94
+ def includes_call?(node)
95
+ node.send_type? && node.method_name == :includes
96
+ end
97
+
98
+ def check(node, violations, file, parent_map)
99
+ depth = max_includes_depth(node)
100
+ count = count_top_level_associations(node.arguments)
101
+
102
+ deep = depth >= @config.max_includes_depth
103
+ wide = count > @config.max_associations
104
+
105
+ return unless deep || wide
106
+
107
+ root = chain_root(node, parent_map)
108
+ methods = collect_chain_methods(root)
109
+
110
+ return if (methods & LIMITING_METHODS).any?
111
+
112
+ issues = []
113
+ issues << "ネスト深さ #{depth}(上限: #{@config.max_includes_depth})" if deep
114
+ issues << "アソシエーション数 #{count}(上限: #{@config.max_associations})" if wide
115
+
116
+ violations << Violation.new(
117
+ file: file,
118
+ line: node.loc.line,
119
+ message: "#{issues.join("、")} かつ件数を絞るメソッド(limit / find 等)がチェーンにありません",
120
+ severity: :warning
121
+ )
122
+ end
123
+
124
+ # ---- 深さ・幅の計算 -------------------------------------------------
125
+
126
+ # includes 引数のネスト深さを計算する
127
+ # :posts → 0
128
+ # { posts: :comments } → 1
129
+ # { posts: { comments: :author } } → 2
130
+ # { posts: [:comments, :likes] } → 1
131
+ def node_depth(node)
132
+ case node.type
133
+ when :hash then 1 + (node.each_pair.map { |pair| node_depth(pair.value) }.max || 0)
134
+ when :array then node.children.map { |child| node_depth(child) }.max || 0
135
+ else 0
136
+ end
137
+ end
138
+
139
+ def max_includes_depth(node)
140
+ node.arguments.map { |arg| node_depth(arg) }.max || 0
141
+ end
142
+
143
+ # トップレベルのアソシエーション数を数える
144
+ # includes(:a, :b) → 2
145
+ # includes(a: :b, c: :d) → 2(ハッシュのキー数)
146
+ # includes(:a, b: :c) → 2(sym + ハッシュキー)
147
+ def count_top_level_associations(args)
148
+ args.sum do |arg|
149
+ case arg.type
150
+ when :sym, :str then 1
151
+ when :hash then arg.keys.size
152
+ else 0
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Bulletproof
7
+ # Rack ミドルウェア。リクエストごとに AR ロード件数・GC 負荷を計測し、
8
+ # 閾値を超えた場合は設定済みの通知先へ RuntimeWarning を送る。
9
+ #
10
+ # 通知先:
11
+ # rails_logger: true → Rails.logger.warn に出力
12
+ # console: true → HTML に console.warn を注入(開発者ツール Console タブ)
13
+ # alert: true → HTML にオーバーレイパネルを注入(画面上で確認)
14
+ # log_file: "path" → ファイルに追記
15
+ # notifier: callable → Slack 等カスタム通知先
16
+ class Middleware
17
+ def initialize(app, config = Bulletproof.config)
18
+ @app = app
19
+ @config = config
20
+ @monitor = Runtime::RequestMonitor.new(config)
21
+ end
22
+
23
+ def call(env)
24
+ response, warnings = @monitor.monitor { @app.call(env) }
25
+ return response if warnings.empty?
26
+
27
+ notify_logger(warnings)
28
+ notify_log_file(warnings)
29
+ notify_custom(warnings)
30
+ inject_html(response, warnings)
31
+ end
32
+
33
+ private
34
+
35
+ # ---- ログ通知 ------------------------------------------------------------
36
+
37
+ def notify_logger(warnings)
38
+ return unless @config.rails_logger
39
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
40
+
41
+ warnings.each { |w| Rails.logger.warn("[Bulletproof] #{w.message}") }
42
+ end
43
+
44
+ def notify_log_file(warnings)
45
+ return unless @config.log_file
46
+
47
+ File.open(@config.log_file, "a") do |f|
48
+ warnings.each do |w|
49
+ f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] [#{w.severity.upcase}] [#{w.type}] #{w.message}"
50
+ end
51
+ end
52
+ end
53
+
54
+ def notify_custom(warnings)
55
+ return unless @config.notifier
56
+
57
+ warnings.each { |w| @config.notifier.call(w) }
58
+ end
59
+
60
+ # ---- HTML 注入 -----------------------------------------------------------
61
+
62
+ def inject_html(response, warnings)
63
+ return response unless @config.console || @config.alert
64
+
65
+ status, headers, body = response
66
+ return response unless html?(headers)
67
+
68
+ content = ""
69
+ content += build_console_script(warnings) if @config.console
70
+ content += build_alert_overlay(warnings) if @config.alert
71
+
72
+ new_body = inject_into_body(body, content)
73
+ [status, update_content_length(headers, new_body), new_body]
74
+ end
75
+
76
+ def html?(headers)
77
+ (headers["content-type"] || "").include?("text/html")
78
+ end
79
+
80
+ # console.warn を発火する <script> タグ
81
+ def build_console_script(warnings)
82
+ lines = warnings.map do |w|
83
+ " console.warn('[Bulletproof] ' + #{JSON.generate(w.message)});"
84
+ end.join("\n")
85
+ "\n<script>\n#{lines}\n</script>"
86
+ end
87
+
88
+ # 画面右下に浮かぶオーバーレイパネル
89
+ def build_alert_overlay(warnings)
90
+ items = warnings.map do |w|
91
+ msg = w.message
92
+ .gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
93
+ .gsub("\n", "<br>&nbsp;&nbsp;")
94
+ "<li style='margin-bottom:6px;'>#{msg}</li>"
95
+ end.join
96
+
97
+ <<~HTML
98
+ <div id="__bp_overlay__" style="position:fixed;bottom:16px;right:16px;max-width:500px;background:#fff3cd;border:2px solid #ffc107;border-radius:8px;padding:12px 16px;font-family:monospace;font-size:12px;line-height:1.5;z-index:999999;box-shadow:0 4px 16px rgba(0,0,0,0.2);">
99
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
100
+ <strong style="color:#856404;font-size:13px;">⚠ Bulletproof (#{warnings.size}件の警告)</strong>
101
+ <button onclick="document.getElementById('__bp_overlay__').remove()" style="background:none;border:none;cursor:pointer;font-size:20px;line-height:1;color:#856404;padding:0 0 0 12px;">✕</button>
102
+ </div>
103
+ <ul style="margin:0;padding:0 0 0 16px;">#{items}</ul>
104
+ </div>
105
+ HTML
106
+ end
107
+
108
+ def inject_into_body(body, content)
109
+ chunks = []
110
+ body.each { |chunk| chunks << chunk } # rubocop:disable Style/MapIntoArray
111
+ injected = false
112
+ chunks.map do |chunk|
113
+ if !injected && chunk.include?("</body>")
114
+ injected = true
115
+ chunk.sub("</body>", "#{content}\n</body>")
116
+ else
117
+ chunk
118
+ end
119
+ end
120
+ end
121
+
122
+ def update_content_length(headers, body)
123
+ return headers unless headers["content-length"]
124
+
125
+ headers.merge("content-length" => body.sum(&:bytesize).to_s)
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ class Railtie < Rails::Railtie
5
+ # config/initializers が読まれた後にミドルウェアを挿入する。
6
+ # これにより、ユーザーが initializer 内で設定した値が反映される。
7
+ initializer "bulletproof.insert_middleware", after: :load_config_initializers do |app|
8
+ app.middleware.use Bulletproof::Middleware if Bulletproof.config.enabled
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ Violation = Data.define(:file, :line, :message, :severity)
5
+
6
+ class Report
7
+ attr_reader :violations
8
+
9
+ def initialize
10
+ @violations = []
11
+ end
12
+
13
+ def add_violation(violation)
14
+ @violations << violation
15
+ self
16
+ end
17
+
18
+ def ok?
19
+ @violations.empty?
20
+ end
21
+
22
+ def to_s
23
+ return "No violations found." if ok?
24
+
25
+ @violations.map do |v|
26
+ "[#{v.severity.upcase}] #{v.file}:#{v.line} — #{v.message}"
27
+ end.join("\n")
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ module Runtime
5
+ # コールスタックからアプリケーションコードのフレームを探すユーティリティ
6
+ #
7
+ # gem 内部・bulletproof 自身のフレームを除外し、
8
+ # 最初にヒットしたアプリケーションコードの位置を返す。
9
+ module CallstackFilter
10
+ # 除外するパスのパターン(gem・bulletproof 自身・eval を対象外にする)
11
+ EXCLUDE_PATTERNS = [
12
+ %r{/gems/}, # bundler でインストールされた gem
13
+ %r{lib/bulletproof}, # bulletproof 自身
14
+ /\A\(eval\)/ # eval されたコード
15
+ ].freeze
16
+
17
+ # @param locations [Array<Thread::Backtrace::Location>]
18
+ # @return [String, nil] "path/to/file.rb:42:in 'method_name'" 形式、見つからなければ nil
19
+ def self.app_location(locations)
20
+ frame = locations.find do |loc|
21
+ path = loc.absolute_path || loc.path || ""
22
+ EXCLUDE_PATTERNS.none? { |pattern| path.match?(pattern) }
23
+ end
24
+ frame&.to_s
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ module Runtime
5
+ # GC 統計を計測するユーティリティ
6
+ # RSS 計測はオーバーヘッドと精度の問題があるため採用しない
7
+ module MemorySampler
8
+ # GC 統計のスナップショットを返す(低オーバーヘッド)
9
+ def self.gc_snapshot
10
+ GC.stat.slice(:count, :total_allocated_objects, :heap_allocated_pages)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ module Runtime
5
+ # リクエスト中に ActiveRecord の instantiation.active_record イベントを購読し、
6
+ # モデルごとのロード件数とロード発生箇所を集計する。
7
+ #
8
+ # スレッドローカル変数を使うため、Puma 等のマルチスレッドサーバーでも
9
+ # 並列リクエスト間で集計が混線しない。
10
+ #
11
+ # 同一モデルが複数回ロードされた場合は件数を合算し、ロード箇所は初回を記録する。
12
+ module ModelLoadCollector
13
+ THREAD_KEY = :__bulletproof_ar_loads__
14
+ private_constant :THREAD_KEY
15
+
16
+ # ブロック実行中のイベントを収集し、ModelLoadEvent の配列を返す(件数降順)
17
+ def self.collect(&block)
18
+ Thread.current[THREAD_KEY] = {}
19
+
20
+ ActiveSupport::Notifications.subscribed(
21
+ method(:handle_event),
22
+ "instantiation.active_record", &block
23
+ )
24
+
25
+ build_events(Thread.current[THREAD_KEY])
26
+ ensure
27
+ Thread.current[THREAD_KEY] = nil
28
+ end
29
+
30
+ # ActiveSupport::Notifications のコールバック形式 (name, start, finish, id, payload)
31
+ # ActiveRecord の find_each / in_batches 経由かどうかを判定するパス
32
+ BATCH_PATH_PATTERN = %r{activerecord.*/relation/batches}
33
+
34
+ def self.handle_event(_name, _start, _finish, _id, payload)
35
+ loads = Thread.current[THREAD_KEY]
36
+ return unless loads
37
+
38
+ model_name = payload[:class_name]
39
+ record_count = payload[:record_count].to_i
40
+ locs = caller_locations(1)
41
+ batched = locs.any? { |l| (l.absolute_path || l.path || "").match?(BATCH_PATH_PATTERN) }
42
+
43
+ if loads.key?(model_name)
44
+ # 2回目以降: 件数を累計、1回の最大値を更新(ロード箇所・batched フラグは初回を保持)
45
+ loads[model_name][:count] += record_count
46
+ loads[model_name][:max_single_load] = [loads[model_name][:max_single_load], record_count].max
47
+ else
48
+ # 初回: アプリコードのフレームをキャプチャ
49
+ location = CallstackFilter.app_location(locs)
50
+ loads[model_name] =
51
+ { count: record_count, max_single_load: record_count, batched: batched, location: location }
52
+ end
53
+ end
54
+ private_class_method :handle_event
55
+
56
+ def self.build_events(loads)
57
+ loads
58
+ .map do |model_name, data|
59
+ ModelLoadEvent.new(
60
+ model_name: model_name,
61
+ record_count: data[:count],
62
+ max_single_load: data[:max_single_load],
63
+ batched: data[:batched],
64
+ caller_location: data[:location]
65
+ )
66
+ end
67
+ .sort_by { |e| -e.record_count }
68
+ end
69
+ private_class_method :build_events
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ module Runtime
5
+ # 1モデルのロード集計結果
6
+ # record_count: リクエスト全体の累計ロード件数(find_each の複数バッチも合算)
7
+ # max_single_load: 1回のイベントで最大何件ロードされたか(find_each のバッチ上限に相当)
8
+ # batched: find_each / in_batches 経由のロードかどうか
9
+ # caller_location: アプリコードで最初にロードが発生した箇所("path:line:in 'method'" 形式)
10
+ ModelLoadEvent = Data.define(:model_name, :record_count, :max_single_load, :batched, :caller_location)
11
+ end
12
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ module Runtime
5
+ # リクエスト中の AR ロード件数・GC 統計を計測し、閾値超過時に RuntimeWarning を生成する
6
+ class RequestMonitor
7
+ Snapshot = Data.define(
8
+ :ar_loads, # Array<ModelLoadEvent> モデルごとのロード件数
9
+ :total_records, # Integer リクエスト全体の合計レコード数
10
+ :gc_count_delta, # Integer GC 実行回数の増分
11
+ :allocated_objects_delta # Integer 生成オブジェクト数の増分
12
+ )
13
+
14
+ def initialize(config)
15
+ @config = config
16
+ end
17
+
18
+ # ブロックを実行し、[戻り値, Array<RuntimeWarning>] を返す
19
+ def monitor
20
+ gc_before = MemorySampler.gc_snapshot
21
+ result = nil
22
+ ar_loads = ModelLoadCollector.collect { result = yield }
23
+ gc_after = MemorySampler.gc_snapshot
24
+
25
+ snapshot = Snapshot.new(
26
+ ar_loads: ar_loads,
27
+ total_records: ar_loads.sum(&:record_count),
28
+ gc_count_delta: gc_after[:count] - gc_before[:count],
29
+ allocated_objects_delta: gc_after[:total_allocated_objects] - gc_before[:total_allocated_objects]
30
+ )
31
+
32
+ [result, build_warnings(snapshot)]
33
+ end
34
+
35
+ private
36
+
37
+ def build_warnings(snapshot)
38
+ warnings = []
39
+
40
+ snapshot.ar_loads.each do |event|
41
+ # max_single_load で判定することで find_each のバッチ処理を誤検知しない
42
+ # find_each(batch_size: 100) → max_single_load: 100 → 閾値以下なら警告しない
43
+ # Post.all.to_a (500件) → max_single_load: 500 → 閾値超えで警告
44
+ next unless event.max_single_load > @config.max_records_per_model
45
+
46
+ location_hint = event.caller_location ? "\n → #{event.caller_location}" : ""
47
+ warnings << RuntimeWarning.new(
48
+ type: :mass_instantiation,
49
+ message: "#{event.model_name} を一度に #{format_count(event.max_single_load)} 件ロードしました" \
50
+ "(上限: #{format_count(@config.max_records_per_model)} 件)#{location_hint}",
51
+ severity: :warning,
52
+ detail: snapshot
53
+ )
54
+ end
55
+
56
+ # find_each / in_batches のバッチ処理は意図的な大量処理なので合計から除外する
57
+ non_batched_loads = snapshot.ar_loads.reject(&:batched)
58
+ non_batched_total = non_batched_loads.sum(&:record_count)
59
+
60
+ if non_batched_total > @config.max_total_records
61
+ summary = non_batched_loads
62
+ .map { |e| "#{e.model_name}: #{format_count(e.record_count)}" }
63
+ .join(", ")
64
+ warnings << RuntimeWarning.new(
65
+ type: :high_total_records,
66
+ message: "リクエスト全体で #{format_count(non_batched_total)} 件のレコードをロードしました" \
67
+ "(上限: #{format_count(@config.max_total_records)} 件)[#{summary}]",
68
+ severity: :warning,
69
+ detail: snapshot
70
+ )
71
+ end
72
+
73
+ if @config.max_gc_runs_per_request && snapshot.gc_count_delta > @config.max_gc_runs_per_request
74
+ warnings << RuntimeWarning.new(
75
+ type: :gc_pressure,
76
+ message: "リクエスト中に GC が #{snapshot.gc_count_delta} 回実行されました" \
77
+ "(上限: #{@config.max_gc_runs_per_request} 回)",
78
+ severity: :warning,
79
+ detail: snapshot
80
+ )
81
+ end
82
+
83
+ warnings
84
+ end
85
+
86
+ def format_count(num)
87
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ RuntimeWarning = Data.define(:type, :message, :severity, :detail)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletproof
4
+ VERSION = "0.1.3"
5
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bulletproof/version"
4
+ require_relative "bulletproof/configuration"
5
+ require_relative "bulletproof/report"
6
+ require_relative "bulletproof/runtime_warning"
7
+ require_relative "bulletproof/detectors/excessive_includes_detector"
8
+ require_relative "bulletproof/analyzer"
9
+ require_relative "bulletproof/railtie" if defined?(Rails::Railtie)
10
+ require_relative "bulletproof/runtime/memory_sampler"
11
+ require_relative "bulletproof/runtime/callstack_filter"
12
+ require_relative "bulletproof/runtime/model_load_event"
13
+ require_relative "bulletproof/runtime/model_load_collector"
14
+ require_relative "bulletproof/runtime/request_monitor"
15
+ require_relative "bulletproof/middleware"
16
+
17
+ module Bulletproof
18
+ class Error < StandardError; end
19
+
20
+ class << self
21
+ def config
22
+ @config ||= Configuration.new
23
+ end
24
+
25
+ def configure
26
+ yield config
27
+ end
28
+
29
+ # @param path [String] 解析対象のファイルまたはディレクトリ
30
+ # @return [Report]
31
+ def analyze(path)
32
+ Analyzer.new(config).call(path)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,4 @@
1
+ module Bulletproof
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bulletproof
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
5
+ platform: ruby
6
+ authors:
7
+ - essei0-0
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-03-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rubocop-ast
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ description: Bulletproof detects ActiveRecord includes that load too many records,
42
+ both statically via source analysis and at runtime via ActiveSupport::Notifications.
43
+ email:
44
+ - hello.essei@gmail.com
45
+ executables:
46
+ - bulletproof
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - ".rspec"
51
+ - ".rubocop.yml"
52
+ - CHANGELOG.md
53
+ - CODE_OF_CONDUCT.md
54
+ - LICENSE.txt
55
+ - README.md
56
+ - Rakefile
57
+ - exe/bulletproof
58
+ - lib/bulletproof.rb
59
+ - lib/bulletproof/analyzer.rb
60
+ - lib/bulletproof/configuration.rb
61
+ - lib/bulletproof/detectors/excessive_includes_detector.rb
62
+ - lib/bulletproof/middleware.rb
63
+ - lib/bulletproof/railtie.rb
64
+ - lib/bulletproof/report.rb
65
+ - lib/bulletproof/runtime/callstack_filter.rb
66
+ - lib/bulletproof/runtime/memory_sampler.rb
67
+ - lib/bulletproof/runtime/model_load_collector.rb
68
+ - lib/bulletproof/runtime/model_load_event.rb
69
+ - lib/bulletproof/runtime/request_monitor.rb
70
+ - lib/bulletproof/runtime_warning.rb
71
+ - lib/bulletproof/version.rb
72
+ - sig/bulletproof.rbs
73
+ homepage: https://github.com/essei0-0/bulletproof
74
+ licenses:
75
+ - MIT
76
+ metadata:
77
+ homepage_uri: https://github.com/essei0-0/bulletproof
78
+ rubygems_mfa_required: 'true'
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.0.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.5.11
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: Linter and runtime monitor for expensive ActiveRecord includes
98
+ test_files: []