hayaoki 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +118 -0
- data/exe/hayaoki +7 -0
- data/lib/hayaoki/boot_report/advisor.rb +113 -0
- data/lib/hayaoki/boot_report/aggregator.rb +367 -0
- data/lib/hayaoki/boot_report/formatters/json.rb +52 -0
- data/lib/hayaoki/boot_report/formatters/redact.rb +32 -0
- data/lib/hayaoki/boot_report/formatters/speedscope.rb +118 -0
- data/lib/hayaoki/boot_report/formatters/text.rb +275 -0
- data/lib/hayaoki/boot_report/gemfile.rb +146 -0
- data/lib/hayaoki/boot_report/report.rb +22 -0
- data/lib/hayaoki/boot_report/rules/base.rb +113 -0
- data/lib/hayaoki/boot_report/rules/bootsnap_ineffective.rb +88 -0
- data/lib/hayaoki/boot_report/rules/controller_action_methods.rb +33 -0
- data/lib/hayaoki/boot_report/rules/dev_gems_in_production.rb +80 -0
- data/lib/hayaoki/boot_report/rules/devise_reload_routes.rb +43 -0
- data/lib/hayaoki/boot_report/rules/eager_load_tradeoff.rb +32 -0
- data/lib/hayaoki/boot_report/rules/heavy_app_initializer.rb +59 -0
- data/lib/hayaoki/boot_report/rules/tzinfo_eager_load.rb +33 -0
- data/lib/hayaoki/boot_report/rules/unused_heavy_gem.rb +149 -0
- data/lib/hayaoki/boot_report/runner.rb +472 -0
- data/lib/hayaoki/cli.rb +210 -0
- data/lib/hayaoki/probe.rb +513 -0
- data/lib/hayaoki/version.rb +5 -0
- data/lib/hayaoki.rb +28 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 70201420261ecfa6d0995fe4d99998a2790c33ab4569bbc80055a99eec4a6720
|
|
4
|
+
data.tar.gz: 5b4fba65fef01f18828f28efba572a4aa683625b307f4d11041b528332f794c5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f6bea9fe2b633032130e6fc71ac051d7f9d65f98282af7edc357bdaec8c2860ff6fa1fc2f1d8bee3203c527a08ac2db6597d1786be0082d3ea0d4593c9ed0143
|
|
7
|
+
data.tar.gz: 85fb1950f0d0c82caf684d00c16dff4b94d6e30fd6914144f7dc298071fde7a758580f89dd23261bb52bfe29bceb473eace37786a62d070fecc5f385a57e7e68
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-11-08
|
|
4
|
+
|
|
5
|
+
- `hayaoki boot-report` measures boot time over several runs and reports the median and range, in text, JSON, or speedscope format.
|
|
6
|
+
- The probe, injected with `ruby -r`, records require, require_relative, load, initializer, on_load, eager_load, `Bundler.require`, and `initialize!` as spans.
|
|
7
|
+
- `config.eager_load_namespaces` entries (e.g. `TZInfo.eager_load!`) are recorded as eager_load spans and attributed to the gem that defines them, instead of disappearing into the `eager_load!` initializer body.
|
|
8
|
+
- Requires are matched to loaded files for `./x`, `../x`, `x.so` (loaded as `.bundle` on macOS) and built-in extensions such as `prism/prism.so`. Before, these fell back to the last loaded file, which could be another thread's. Remaining fallbacks are counted and reported as a warning.
|
|
9
|
+
- Before measuring, the child `ruby` is checked to be Ruby 3.2 or later, with a one-line error that points to `--ruby` otherwise.
|
|
10
|
+
- Phase breakdown (vm boot, bundler require, initialize) and per-gem and per-initializer rankings.
|
|
11
|
+
- Improvement suggestions, printed as candidates: bootsnap effectiveness, heavy app initializers, unused heavy gems, dev gems in production, and eager load trade-offs. Disable with `--no-suggestions`.
|
|
12
|
+
- `tzinfo_eager_load` suggestion: `TZInfo.eager_load!` takes 200–330 ms in Linux containers (see `bench/tzinfo_eager_load.rb`), and it can be made lazy with one line.
|
|
13
|
+
- `devise_reload_routes` and `controller_action_methods` suggestions, measured on Mastodon and Redmine with `bench/boot_variants.rb`.
|
|
14
|
+
- A ranking of `config.eager_load_namespaces` entries in the text output, and `eager_namespaces` in the JSON output.
|
|
15
|
+
- Options for runs, environment, bootsnap mode (`warm`, `cold`, `both`), entry file, timeout, output file, and rows per ranking, plus thresholds for suggestions.
|
|
16
|
+
- The probe has no runtime dependencies and writes JSON with its own encoder.
|
|
17
|
+
- `--redact` replaces absolute paths in the output (text, JSON, and speedscope): the app directory with `<app>`, gem paths (including bundler git gems) with `<gem:name>/`, and the home directory with `~`.
|
|
18
|
+
- A category table in the text and JSON output, with an `unattributed` row (boot time minus attributed self time) so the totals add up.
|
|
19
|
+
- Applications that are themselves gems (`gemspec`, engine dummy apps, `path: "."`) are categorized as `app` for files under `app`, `config`, `lib`, and `db`.
|
|
20
|
+
- JSON output omits the stderr of failed runs, which may contain secrets. It keeps the message, exit status, and error class.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Takato Hano
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# hayaoki
|
|
2
|
+
|
|
3
|
+
Wake your Rails app up early: find out where its boot time goes, and what to change.
|
|
4
|
+
|
|
5
|
+
*hayaoki* (早起き) means "getting up early" in Japanese. It is a diagnostic tool: it measures and explains boot time, and does not make boot faster just by being installed.
|
|
6
|
+
|
|
7
|
+
hayaoki measures where a Rails application's boot time goes and reports it by phase, by gem, and by initializer. The current release provides `hayaoki boot-report`.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
gem install hayaoki
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
hayaoki is a standalone command. You do not need to add it to your Gemfile.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
hayaoki boot-report [APP_DIR]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`APP_DIR` defaults to the current directory.
|
|
24
|
+
|
|
25
|
+
| Option | Description |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `--runs N` | number of measured runs (default 5) |
|
|
28
|
+
| `--env ENV` | `RAILS_ENV` (default `production`) |
|
|
29
|
+
| `--bootsnap MODE` | `warm`, `cold`, or `both` (default `warm`) |
|
|
30
|
+
| `--ruby PATH` | ruby executable (default `ruby`) |
|
|
31
|
+
| `--entry PATH` | boot entry file (default `config/environment`) |
|
|
32
|
+
| `--timeout SEC` | per-run timeout in seconds (default 120) |
|
|
33
|
+
| `--format FORMAT` | `text`, `json`, or `speedscope` (default `text`) |
|
|
34
|
+
| `--output FILE` | write to FILE instead of stdout |
|
|
35
|
+
| `--top N` | rows per ranking (default 10) |
|
|
36
|
+
| `--no-suggestions` | do not print improvement suggestions |
|
|
37
|
+
| `--initializer-threshold MS` | app initializer threshold for suggestions (default 50) |
|
|
38
|
+
| `--gem-threshold MS` | gem self time threshold for suggestions (default 30) |
|
|
39
|
+
| `--redact` | replace absolute paths in the output (all formats): the app directory (and its realpath) with `<app>`, gem paths with `<gem:name>/`, the home directory with `~` |
|
|
40
|
+
| `--version` | show version |
|
|
41
|
+
| `-h`, `--help` | show help |
|
|
42
|
+
|
|
43
|
+
Example output:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
total[warm]: 687.8 ms [684.3-699.4]
|
|
47
|
+
Phases (median)
|
|
48
|
+
vm boot █ 28.2 ms
|
|
49
|
+
bundler require ████████ 195.6 ms
|
|
50
|
+
initialize ███████████ 267.0 ms
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Suggestions
|
|
54
|
+
|
|
55
|
+
| Rule | Confidence | What it looks for |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `dev_gems_in_production` | high / medium / low | development or test gems loaded in production (high: direct and not in a group; low: transitive) |
|
|
58
|
+
| `bootsnap_ineffective` | high | bootsnap missing, or its cache not helping |
|
|
59
|
+
| `heavy_app_initializer` | medium | app initializers slower than `--initializer-threshold` |
|
|
60
|
+
| `tzinfo_eager_load` | medium | `TZInfo.eager_load!` loading every time zone at boot (see below) |
|
|
61
|
+
| `devise_reload_routes` | medium (Devise 5+) / low | Devise reloading the routes before eager loading; they are loaded again later in boot |
|
|
62
|
+
| `controller_action_methods` | low | `AbstractController.eager_load!` computing `action_methods` for every controller at boot |
|
|
63
|
+
| `unused_heavy_gem` | low | heavy gems with no references in app code (candidates for `require: false`) |
|
|
64
|
+
| `eager_load_tradeoff` | info | eager loading taking a large share of boot |
|
|
65
|
+
|
|
66
|
+
Estimated savings are upper bounds. Findings can overlap, and the output marks which ones do, so do not add them up.
|
|
67
|
+
|
|
68
|
+
### TZInfo eager loading
|
|
69
|
+
|
|
70
|
+
In production, Rails 7.2 and later add `TZInfo` to `config.eager_load_namespaces`, and `TZInfo.eager_load!` reads all ~600 time zones at boot. Measured with [`bench/tzinfo_eager_load.rb`](https://github.com/TakatoHano/hayaoki/blob/main/bench/tzinfo_eager_load.rb) (median of 7 fresh processes on a GitHub-hosted runner, Ruby 3.4):
|
|
71
|
+
|
|
72
|
+
| Environment | `TZInfo.eager_load!` |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `ruby:3.4-slim`, system zoneinfo / with `tzinfo-data` | 243 ms / 194 ms |
|
|
75
|
+
| `ruby:3.4-alpine` + `tzdata` / with `tzinfo-data` | 330 ms / 228 ms |
|
|
76
|
+
| AWS Lambda `ruby:3.4`, system zoneinfo / with `tzinfo-data` | 319 ms / 202 ms |
|
|
77
|
+
| macOS (Apple silicon, local) | 112 ms |
|
|
78
|
+
|
|
79
|
+
Removing it (`Rails.application.config.eager_load_namespaces.delete(TZInfo)` in an initializer) leaves 5–15 ms at boot. Each zone is then loaded on first use, in under 1 ms. The trade-off is that forking servers no longer share the zone data copy-on-write.
|
|
80
|
+
|
|
81
|
+
### Measured on real applications
|
|
82
|
+
|
|
83
|
+
[`bench/boot_variants.rb`](https://github.com/TakatoHano/hayaoki/blob/main/bench/boot_variants.rb) boots an application with one setting changed at a time (median of fresh processes, GitHub-hosted runner, official Docker images, `RAILS_ENV=production`). The `real-apps` workflow runs it. Boot times vary by several hundred ms between runners, so compare within one run.
|
|
84
|
+
|
|
85
|
+
| Change | Mastodon 4.7.5 | Redmine 7.0 | Cost moved to later |
|
|
86
|
+
| --- | --- | --- | --- |
|
|
87
|
+
| `eager_load_namespaces.delete(TZInfo)` | −128 ms | −193 to −328 ms | under 1 ms per zone used |
|
|
88
|
+
| Devise `config.reload_routes = false` | −194 ms | (no Devise) | none: the routes are loaded once, later in boot |
|
|
89
|
+
| `eager_load_namespaces.delete(AbstractController)` | −201 ms | −31 ms | 217 ms in total across 336 controllers, about 0.65 ms on each controller's first request |
|
|
90
|
+
| `eager_load_namespaces.delete(I18n)` | −108 ms | −286 ms | 182 ms / 317 ms on the first translation |
|
|
91
|
+
| `config.i18n.available_locales = [:en]` | no gain | no gain | every locale file is still parsed |
|
|
92
|
+
|
|
93
|
+
hayaoki has no I18n suggestion: making it lazy only moves the whole cost to the first translation, which nearly every request needs.
|
|
94
|
+
|
|
95
|
+
## How it works
|
|
96
|
+
|
|
97
|
+
hayaoki runs your application in a child process with a probe injected through `ruby -r`. The probe hooks the require family of methods with an alias chain, not `prepend`, so it coexists with bootsnap, Zeitwerk, and bundled_gems. Rails-specific hooks (initializers, on_load hooks, eager loading, `Bundler.require`, and `initialize!`) are installed lazily, when the corresponding file is loaded. The measured overhead is about 2%.
|
|
98
|
+
|
|
99
|
+
The design is described in [docs/design/probe.md](https://github.com/TakatoHano/hayaoki/blob/main/docs/design/probe.md).
|
|
100
|
+
|
|
101
|
+
## Notes
|
|
102
|
+
|
|
103
|
+
- The application is booted with the `ruby` found on `PATH` in the application directory (so rbenv, asdf, or mise pick the app's version), or with `--ruby PATH`. It must be Ruby 3.2 or later; hayaoki checks this before measuring.
|
|
104
|
+
- By default hayaoki boots the application with `RAILS_ENV=production`. Boot may then need access to external services or credentials. If so, use `SECRET_KEY_BASE_DUMMY=1` for a dummy secret key, and `--env` to choose another environment.
|
|
105
|
+
- Suggestions are candidates. Verify each one before applying it.
|
|
106
|
+
- Output contains absolute paths unless you pass `--redact`. `--redact` rewrites paths only. It does not remove other content, so review the output before sharing it.
|
|
107
|
+
- JSON output does not include the stderr of failed runs, because it may contain secrets. It keeps the error message, exit status, and error class. When every run fails, hayaoki prints the stderr tail to the terminal.
|
|
108
|
+
- The category table (`gem`, `app`, `stdlib`, `other`, `init_body`, `unattributed`) sums to boot time. `init_body` is the own time of initializer blocks (not blamed on the gem that defines them). `unattributed` is boot time minus the self time attributed to files, initializer bodies, and on_load hooks. It covers time in `run_load_hooks` itself, the self time of phase and Zeitwerk eager load spans, and time outside any span. Background-thread spans are excluded.
|
|
109
|
+
|
|
110
|
+
## Requirements
|
|
111
|
+
|
|
112
|
+
- Ruby 3.2 or later
|
|
113
|
+
- Rails 7.0 or later
|
|
114
|
+
- macOS or Linux
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT. See [LICENSE](LICENSE).
|
data/exe/hayaoki
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rules/base"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
# 改善候補 1 件。evidence は String キーの Hash の配列。
|
|
8
|
+
Suggestion = Struct.new(:rule, :confidence, :title, :evidence, :estimated_savings_ms, :action, :id, :overlaps, keyword_init: true) do
|
|
9
|
+
# 各 finding の節約見込みは重複しうるので足し算では出さず、上限 (単純和) として扱う。
|
|
10
|
+
def self.savings_upper_bound(list)
|
|
11
|
+
v = list.filter_map(&:estimated_savings_ms)
|
|
12
|
+
v.empty? ? nil : v.sum.round(1)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{ "id" => id, "overlaps" => Array(overlaps), "rule" => rule.to_s, "confidence" => confidence.to_s, "title" => title, "evidence" => evidence,
|
|
17
|
+
"estimated_savings_ms" => estimated_savings_ms, "action" => action }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Report (と RunResult) から Suggestion を作る。ルールの例外は警告にして続行する。
|
|
22
|
+
class Advisor
|
|
23
|
+
CONFIDENCE_ORDER = %i[high medium low info].freeze
|
|
24
|
+
DEFAULT_INITIALIZER_THRESHOLD_MS = 50
|
|
25
|
+
DEFAULT_GEM_THRESHOLD_MS = 30
|
|
26
|
+
|
|
27
|
+
attr_reader :warnings
|
|
28
|
+
|
|
29
|
+
def self.rules
|
|
30
|
+
require_relative "rules/dev_gems_in_production"
|
|
31
|
+
require_relative "rules/bootsnap_ineffective"
|
|
32
|
+
require_relative "rules/heavy_app_initializer"
|
|
33
|
+
require_relative "rules/unused_heavy_gem"
|
|
34
|
+
require_relative "rules/eager_load_tradeoff"
|
|
35
|
+
require_relative "rules/tzinfo_eager_load"
|
|
36
|
+
require_relative "rules/devise_reload_routes"
|
|
37
|
+
require_relative "rules/controller_action_methods"
|
|
38
|
+
[Rules::DevGemsInProduction, Rules::BootsnapIneffective, Rules::HeavyAppInitializer,
|
|
39
|
+
Rules::UnusedHeavyGem, Rules::EagerLoadTradeoff, Rules::TzinfoEagerLoad,
|
|
40
|
+
Rules::DeviseReloadRoutes, Rules::ControllerActionMethods]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(report, app_dir:, result: nil, initializer_threshold_ms: DEFAULT_INITIALIZER_THRESHOLD_MS,
|
|
44
|
+
gem_threshold_ms: DEFAULT_GEM_THRESHOLD_MS, rules: self.class.rules)
|
|
45
|
+
@ctx = Context.new(report: report, app_dir: app_dir, result: result,
|
|
46
|
+
initializer_threshold_ms: initializer_threshold_ms, gem_threshold_ms: gem_threshold_ms)
|
|
47
|
+
@rules = rules
|
|
48
|
+
@warnings = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def suggestions
|
|
52
|
+
@suggestions ||= run
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def run
|
|
58
|
+
out = @rules.flat_map do |klass|
|
|
59
|
+
Array(klass.new(@ctx).call)
|
|
60
|
+
rescue StandardError, NotImplementedError => e
|
|
61
|
+
id = klass.respond_to?(:rule_id) ? klass.rule_id : klass.to_s
|
|
62
|
+
@warnings << "suggestion rule #{id} failed: #{e.class}: #{e.message}"[0, 300]
|
|
63
|
+
[]
|
|
64
|
+
end
|
|
65
|
+
sorted = out.each_with_index.sort_by { |s, i| [CONFIDENCE_ORDER.index(s.confidence) || 99, -(s.estimated_savings_ms || 0), i] }.map(&:first)
|
|
66
|
+
mark_overlaps(sorted)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
GEM_EVIDENCE_KEY = { dev_gems_in_production: "name", unused_heavy_gem: "gem" }.freeze
|
|
70
|
+
GEM_RULES = GEM_EVIDENCE_KEY.keys.freeze
|
|
71
|
+
|
|
72
|
+
# id を振り、節約見込みが重なりうる finding を overlaps に載せる。
|
|
73
|
+
# - 同じ gem を別ルールが指している
|
|
74
|
+
# - app initializer の inclusive 時間には gem の require が含まれうるので、gem 系の finding と重なる
|
|
75
|
+
# - TZInfo.eager_load! などの時間は eager_load フェーズに含まれる
|
|
76
|
+
def mark_overlaps(list)
|
|
77
|
+
seen = Hash.new(0)
|
|
78
|
+
list.each do |s|
|
|
79
|
+
seen[s.rule] += 1
|
|
80
|
+
s.id = seen[s.rule] == 1 ? s.rule.to_s : "#{s.rule}##{seen[s.rule]}"
|
|
81
|
+
end
|
|
82
|
+
gems = {}.compare_by_identity
|
|
83
|
+
list.each { |s| gems[s] = gem_subjects(s) }
|
|
84
|
+
list.each do |s|
|
|
85
|
+
s.overlaps = list.filter_map do |o|
|
|
86
|
+
next if o.equal?(s)
|
|
87
|
+
o.id if (gems[s] & gems[o]).any? || initializer_vs_gem?(s, o) || eager_phase_pair?(s, o)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
list
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# eager_load フェーズの中の処理を指す finding は、eager_load_tradeoff と節約見込みが重なる
|
|
94
|
+
EAGER_PHASE_RULES = %i[tzinfo_eager_load controller_action_methods].freeze
|
|
95
|
+
|
|
96
|
+
def eager_phase_pair?(a, b)
|
|
97
|
+
pair = [a.rule, b.rule]
|
|
98
|
+
pair.include?(:eager_load_tradeoff) && pair.any? { |r| EAGER_PHASE_RULES.include?(r) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def initializer_vs_gem?(a, b)
|
|
102
|
+
pair = [a.rule, b.rule]
|
|
103
|
+
pair.include?(:heavy_app_initializer) && pair.any? { |r| GEM_RULES.include?(r) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def gem_subjects(s)
|
|
107
|
+
key = GEM_EVIDENCE_KEY[s.rule]
|
|
108
|
+
return [] unless key
|
|
109
|
+
Array(s.evidence).filter_map { |e| e[key] if e.is_a?(Hash) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "report"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
# probe の payload (Runner が集めた複数回分) を、設計書 §3.4 の正規化をかけた木にして集計する。
|
|
8
|
+
# 時間はすべて ms。各指標は { "median", "min", "max" } (偶数個なら中央2つの平均)。
|
|
9
|
+
class Aggregator
|
|
10
|
+
K_REQ = 0; K_RR = 1; K_LOAD = 2; K_INIT = 3; K_ONLOAD = 4; K_HOOKS = 5; K_EAGER = 6; K_PHASE = 7
|
|
11
|
+
|
|
12
|
+
Span = Struct.new(:idx, :kind, :name, :parent, :t0, :t1, :res, :tid, :cpu, :children, :incomplete, :merged, :owner) do
|
|
13
|
+
def require_kind? = kind <= K_LOAD
|
|
14
|
+
def dur_us = t1 - t0
|
|
15
|
+
def raised? = res == "raised"
|
|
16
|
+
# require 系は feature (res)。raised や非 String は nil
|
|
17
|
+
def feature = require_kind? && res.is_a?(String) && !raised? ? res : nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Owner = Struct.new(:category, :name) # category: "gem" / "app" / "stdlib" / "other"
|
|
21
|
+
|
|
22
|
+
def initialize(top_files: 30, top_initializers: 30, top_on_loads: 30)
|
|
23
|
+
@top_files = top_files
|
|
24
|
+
@top_initializers = top_initializers
|
|
25
|
+
@top_on_loads = top_on_loads
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# RunResult → { condition(String) => Report }
|
|
29
|
+
def aggregate_result(result)
|
|
30
|
+
result.payloads.to_h do |cond, list|
|
|
31
|
+
[cond, aggregate(list, condition: cond, meta: result.meta)]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def aggregate(payloads, condition: nil, meta: {})
|
|
36
|
+
ok, bad = payloads.partition { |p| p["status"] == "ok" }
|
|
37
|
+
warnings = Array(meta["warnings"]).dup
|
|
38
|
+
warnings << "#{bad.size} failed run(s) excluded from aggregation" unless bad.empty?
|
|
39
|
+
warnings.concat(ok.flat_map { |p| Array(p["probe_warnings"]) + Array(p["probe_errors"]).map { |e| "probe error: #{e}" } }.uniq)
|
|
40
|
+
fb = ok.map { |p| p.dig("resolve_fallbacks", "count").to_i }.max.to_i
|
|
41
|
+
if fb.positive?
|
|
42
|
+
ex = ok.flat_map { |p| Array(p.dig("resolve_fallbacks", "examples")) }.uniq.first(5)
|
|
43
|
+
warnings << "#{fb} require(s) could not be matched to a loaded file and were attributed to the last loaded file; " \
|
|
44
|
+
"attribution may be off (e.g. #{ex.join(", ")})"
|
|
45
|
+
end
|
|
46
|
+
analyses = ok.map { |p| analyze(p) }
|
|
47
|
+
thread_spans = analyses.map { |a| a[:background_spans] }.max.to_i
|
|
48
|
+
warnings << "#{thread_spans} span(s) recorded on background threads; attribution is approximate and they are excluded from totals" if thread_spans.positive?
|
|
49
|
+
merged = analyses.sum { |a| a[:merged] }
|
|
50
|
+
|
|
51
|
+
Report.new(
|
|
52
|
+
condition: condition, runs: ok.size, failed: bad.size,
|
|
53
|
+
ruby: ok.first && ok.first["ruby"], features: stat(analyses.map { |a| a[:features] }),
|
|
54
|
+
phases: phases(analyses),
|
|
55
|
+
categories: keyed_stats(analyses.map { |a| a[:categories] }, order_by: "self_ms"),
|
|
56
|
+
gems: gems(analyses),
|
|
57
|
+
files: files(analyses),
|
|
58
|
+
initializers: initializers(analyses),
|
|
59
|
+
app_initializer_times: app_initializer_times(analyses),
|
|
60
|
+
on_loads: on_loads(analyses),
|
|
61
|
+
eager_namespaces: eager_namespaces(analyses),
|
|
62
|
+
false_require: {
|
|
63
|
+
"count" => stat(analyses.map { |a| a[:false_count] }), "time_ms" => stat(analyses.map { |a| a[:false_ms] })
|
|
64
|
+
},
|
|
65
|
+
threads: { "background_spans" => thread_spans, "merged_duplicates" => merged },
|
|
66
|
+
meta: meta, warnings: warnings.uniq
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# 1 回分の payload を解析する。テストや formatter 用に公開しておく。
|
|
71
|
+
def analyze(payload)
|
|
72
|
+
spans = build_spans(payload)
|
|
73
|
+
merged = normalize!(spans)
|
|
74
|
+
spans = spans.reject(&:merged)
|
|
75
|
+
resolver = OwnerResolver.new(payload)
|
|
76
|
+
main = spans.select { |s| s.tid.to_i == 0 }
|
|
77
|
+
pmap = spans.to_h { |s| [s.idx, s] }
|
|
78
|
+
main.each { |s| s.owner = owner_of(s, resolver) }
|
|
79
|
+
selfs = {}
|
|
80
|
+
spans.each { |s| selfs[s] = s.dur_us - s.children.sum { |c| c.dur_us } }
|
|
81
|
+
|
|
82
|
+
a = {
|
|
83
|
+
merged: merged, features: payload["features"], background_spans: spans.size - main.size,
|
|
84
|
+
false_count: payload.dig("false_require", "count").to_i, false_ms: payload.dig("false_require", "time_us").to_i / 1000.0,
|
|
85
|
+
gems: Hash.new { |h, k| h[k] = { "self_ms" => 0.0, "require_ms" => 0.0, "on_load_ms" => 0.0, "eager_ms" => 0.0, "initializer_count" => 0, "category" => nil } },
|
|
86
|
+
categories: Hash.new { |h, k| h[k] = { "self_ms" => 0.0 } },
|
|
87
|
+
files: Hash.new { |h, k| h[k] = { "self_ms" => 0.0, "inclusive_ms" => 0.0, "owner" => nil, "category" => nil, "count" => 0 } },
|
|
88
|
+
initializers: Hash.new { |h, k| h[k] = { "inclusive_ms" => 0.0, "init_body_ms" => 0.0 } },
|
|
89
|
+
on_loads: Hash.new { |h, k| h[k] = { "inclusive_ms" => 0.0, "self_ms" => 0.0 } },
|
|
90
|
+
eager_namespaces: Hash.new { |h, k| h[k] = { "inclusive_ms" => 0.0, "self_ms" => 0.0 } },
|
|
91
|
+
phase: {}
|
|
92
|
+
}
|
|
93
|
+
spawn_at = payload["spawn_at"]&.to_f
|
|
94
|
+
boot_us = payload["boot_end_us"] || payload["exit_us"]
|
|
95
|
+
a[:phase]["vm_boot"] = (payload["t0_real"] - spawn_at) * 1000.0 if spawn_at && payload["t0_real"]
|
|
96
|
+
a[:phase]["boot"] = boot_us / 1000.0 if boot_us
|
|
97
|
+
a[:phase]["total"] = (a[:phase]["vm_boot"] || 0.0) + boot_us / 1000.0 if boot_us
|
|
98
|
+
|
|
99
|
+
main.each do |s|
|
|
100
|
+
self_ms = selfs[s] / 1000.0
|
|
101
|
+
dur_ms = s.dur_us / 1000.0
|
|
102
|
+
o = s.owner
|
|
103
|
+
case s.kind
|
|
104
|
+
when K_REQ, K_RR, K_LOAD
|
|
105
|
+
add_owner(a, o, self_ms, "require_ms")
|
|
106
|
+
f = a[:files][s.feature || s.name.to_s]
|
|
107
|
+
f["self_ms"] += self_ms
|
|
108
|
+
f["inclusive_ms"] += dur_ms
|
|
109
|
+
f["owner"] = o.name
|
|
110
|
+
f["category"] = o.category
|
|
111
|
+
f["count"] += 1
|
|
112
|
+
when K_ONLOAD
|
|
113
|
+
add_owner(a, o, self_ms, "on_load_ms")
|
|
114
|
+
e = a[:on_loads][[s.name, s.res.is_a?(String) ? s.res : nil]]
|
|
115
|
+
e["inclusive_ms"] += dur_ms
|
|
116
|
+
e["self_ms"] += self_ms
|
|
117
|
+
e["owner"] = o.name
|
|
118
|
+
e["category"] = o.category
|
|
119
|
+
when K_INIT
|
|
120
|
+
# initializer 本体の自己時間 (require や子に帰属できない分) は定義元 gem に付けず、init_body_ms として別掲する
|
|
121
|
+
ctx = s.res.is_a?(Array) ? s.res[0] : nil
|
|
122
|
+
e = a[:initializers][[s.name, ctx]]
|
|
123
|
+
e["inclusive_ms"] += dur_ms
|
|
124
|
+
e["init_body_ms"] += self_ms
|
|
125
|
+
a[:categories]["init_body"]["self_ms"] += self_ms
|
|
126
|
+
e["owner"] = o.name
|
|
127
|
+
# 時間は付けないが、initializer を定義していること (= Railtie/Engine) は記録する
|
|
128
|
+
g = a[:gems][o.name]
|
|
129
|
+
g["category"] = o.category
|
|
130
|
+
g["initializer_count"] += 1
|
|
131
|
+
e["category"] = o.category
|
|
132
|
+
when K_PHASE
|
|
133
|
+
key = s.name
|
|
134
|
+
a[:phase][key] = a[:phase][key].to_f + dur_ms if %w[bundler_require initialize].include?(key)
|
|
135
|
+
when K_EAGER
|
|
136
|
+
# eager_load_namespaces の span (meta = 定義位置) は自己時間を持ち主に付ける。Zeitwerk の span は従来どおり。
|
|
137
|
+
if s.res.is_a?(String)
|
|
138
|
+
add_owner(a, o, self_ms, "eager_ms")
|
|
139
|
+
e = a[:eager_namespaces][s.name]
|
|
140
|
+
e["inclusive_ms"] += dur_ms
|
|
141
|
+
e["self_ms"] += self_ms
|
|
142
|
+
e["owner"] = o.name
|
|
143
|
+
e["category"] = o.category
|
|
144
|
+
end
|
|
145
|
+
a[:phase]["eager_load"] = a[:phase]["eager_load"].to_f + dur_ms unless eager_ancestor?(s, pmap)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
# 帰属されなかった時間 (run_load_hooks 自体、phase / eager_load span の自己時間、span の外) を別掲する
|
|
149
|
+
if boot_us
|
|
150
|
+
attributed = a[:categories].values.sum { |c| c["self_ms"] }
|
|
151
|
+
a[:categories]["unattributed"]["self_ms"] = [boot_us / 1000.0 - attributed, 0.0].max
|
|
152
|
+
end
|
|
153
|
+
a
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def stat(values)
|
|
159
|
+
v = values.compact
|
|
160
|
+
return nil if v.empty?
|
|
161
|
+
s = v.sort
|
|
162
|
+
n = s.size
|
|
163
|
+
med = n.odd? ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2.0
|
|
164
|
+
{ "median" => r(med), "min" => r(s.first), "max" => r(s.last) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def r(x) = x.is_a?(Float) ? x.round(3) : x
|
|
168
|
+
|
|
169
|
+
def phases(analyses)
|
|
170
|
+
keys = %w[vm_boot bundler_require initialize eager_load boot total]
|
|
171
|
+
keys.to_h { |k| [k, stat(analyses.map { |a| a[:phase][k] })] }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# runs 個の Hash (key => { metric => number, ... }) を key ごとに metric ごとの stat にする。
|
|
175
|
+
# 欠けた run は 0 として扱う。非数値の属性 (owner など) は最初に見つかった値を使う。
|
|
176
|
+
def merge_runs(list)
|
|
177
|
+
keys = list.flat_map(&:keys).uniq
|
|
178
|
+
keys.to_h do |k|
|
|
179
|
+
rows = list.map { |h| h[k] }
|
|
180
|
+
sample = rows.compact.first
|
|
181
|
+
out = {}
|
|
182
|
+
sample.each do |m, v|
|
|
183
|
+
out[m] = v.is_a?(Numeric) ? stat(rows.map { |row| row ? row[m] : 0 }) : v
|
|
184
|
+
end
|
|
185
|
+
[k, out]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def keyed_stats(list, order_by:)
|
|
190
|
+
merge_runs(list).sort_by { |_, v| -v[order_by]["median"] }.to_h
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def gems(analyses)
|
|
194
|
+
merged = merge_runs(analyses.map { |a| a[:gems].to_h })
|
|
195
|
+
merged.sort_by { |k, v| [-v["self_ms"]["median"], k] }.map { |k, v| { "name" => k }.merge(v) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def files(analyses)
|
|
199
|
+
merged = merge_runs(analyses.map { |a| a[:files].to_h })
|
|
200
|
+
merged.sort_by { |k, v| [-v["self_ms"]["median"], k] }.first(@top_files).map { |k, v| { "path" => k }.merge(v) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def initializers(analyses)
|
|
204
|
+
merged = merge_runs(analyses.map { |a| a[:initializers].to_h })
|
|
205
|
+
merged.sort_by { |k, v| [-v["inclusive_ms"]["median"], k.to_s] }.first(@top_initializers).map do |(name, ctx), v|
|
|
206
|
+
{ "name" => name, "context" => ctx }.merge(v)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Advisor (heavy_app_initializer) 用。表示用の top-N 切り詰めをかけない app 側の inclusive 時間。
|
|
211
|
+
def app_initializer_times(analyses)
|
|
212
|
+
inits = merge_runs(analyses.map { |a| a[:initializers].to_h }).select { |_, v| v["category"] == "app" }
|
|
213
|
+
files = merge_runs(analyses.map { |a| a[:files].to_h }).select { |k, v| v["category"] == "app" && k.include?("/config/initializers/") }
|
|
214
|
+
{
|
|
215
|
+
"initializers" => inits.map { |(name, ctx), v| { "name" => name, "context" => ctx, "category" => "app", "inclusive_ms" => v["inclusive_ms"] } },
|
|
216
|
+
"files" => files.map { |k, v| { "path" => k, "category" => "app", "inclusive_ms" => v["inclusive_ms"] } }
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def on_loads(analyses)
|
|
221
|
+
merged = merge_runs(analyses.map { |a| a[:on_loads].to_h })
|
|
222
|
+
merged.sort_by { |k, v| [-v["inclusive_ms"]["median"], k.to_s] }.first(@top_on_loads).map do |(name, loc), v|
|
|
223
|
+
{ "name" => name, "source" => loc }.merge(v)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# config.eager_load_namespaces の eager_load! (TZInfo.eager_load! など)。件数は数十なので切り詰めない。
|
|
228
|
+
def eager_namespaces(analyses)
|
|
229
|
+
merged = merge_runs(analyses.map { |a| a[:eager_namespaces].to_h })
|
|
230
|
+
merged.sort_by { |k, v| [-v["inclusive_ms"]["median"], k.to_s] }.map { |name, v| { "name" => name }.merge(v) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def add_owner(a, owner, self_ms, kind_key)
|
|
234
|
+
# app / stdlib / other も gems の表に並べる (ランキングで比較できるように)
|
|
235
|
+
g = a[:gems][owner.name]
|
|
236
|
+
g["category"] = owner.category
|
|
237
|
+
g["self_ms"] += self_ms
|
|
238
|
+
g[kind_key] += self_ms
|
|
239
|
+
a[:categories][owner.category]["self_ms"] += self_ms
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# 入れ子の eager_load (eager_load_all が各 loader の eager_load を呼ぶ形) を二重に数えない
|
|
243
|
+
def eager_ancestor?(s, pmap)
|
|
244
|
+
while (s = pmap[s.parent])
|
|
245
|
+
return true if s.kind == K_EAGER
|
|
246
|
+
end
|
|
247
|
+
false
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# --- 木の構築 -----------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def build_spans(payload)
|
|
253
|
+
w = payload["width"] || 8
|
|
254
|
+
ev = payload["events"] || []
|
|
255
|
+
exit_us = payload["exit_us"] || ev.each_slice(w).filter_map { |e| e[4] }.max || 0
|
|
256
|
+
spans = []
|
|
257
|
+
by_idx = {}
|
|
258
|
+
(0...(ev.size / w)).each do |i|
|
|
259
|
+
kind, name, par, t0, t1, res, tid, cpu = ev[i * w, w]
|
|
260
|
+
incomplete = t1.nil?
|
|
261
|
+
s = Span.new(i * w, kind, name, par, t0, t1 || [exit_us, t0].max, res, tid, cpu, [], incomplete, false, nil)
|
|
262
|
+
spans << s
|
|
263
|
+
by_idx[s.idx] = s
|
|
264
|
+
end
|
|
265
|
+
spans.each do |s|
|
|
266
|
+
p = by_idx[s.parent]
|
|
267
|
+
p.children << s if p
|
|
268
|
+
end
|
|
269
|
+
spans
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# §3.4: 唯一の子が同じ feature の require 系なら 1 つにまとめる。まとめた件数を返す。
|
|
273
|
+
def normalize!(spans)
|
|
274
|
+
count = 0
|
|
275
|
+
spans.each do |s|
|
|
276
|
+
next if s.merged
|
|
277
|
+
while s.require_kind? && s.feature && s.children.size == 1
|
|
278
|
+
c = s.children.first
|
|
279
|
+
break unless c.require_kind? && c.feature == s.feature && c.tid == s.tid
|
|
280
|
+
c.merged = true
|
|
281
|
+
s.children = c.children
|
|
282
|
+
s.children.each { |g| g.parent = s.idx }
|
|
283
|
+
count += 1
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
# 外側から順に処理するので、親が先に処理される (EV は先行順)
|
|
287
|
+
count
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# --- 帰属 ---------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def owner_of(s, resolver)
|
|
293
|
+
case s.kind
|
|
294
|
+
when K_REQ, K_RR, K_LOAD
|
|
295
|
+
path = s.feature || (s.name.to_s.start_with?("/") ? s.name.to_s : nil)
|
|
296
|
+
path ? resolver.owner(path) : Owner.new("other", "other")
|
|
297
|
+
when K_ONLOAD
|
|
298
|
+
loc = s.res.is_a?(String) ? s.res.sub(/:\d+\z/, "") : nil
|
|
299
|
+
loc ? resolver.owner(loc) : Owner.new("other", "other")
|
|
300
|
+
when K_EAGER
|
|
301
|
+
s.res.is_a?(String) ? resolver.owner(s.res.sub(/:\d+\z/, "")) : Owner.new("other", "other")
|
|
302
|
+
when K_INIT
|
|
303
|
+
loc = s.res.is_a?(Array) && s.res[1].is_a?(String) ? s.res[1].sub(/:\d+\z/, "") : nil
|
|
304
|
+
loc ? resolver.owner(loc) : Owner.new("other", "other")
|
|
305
|
+
else
|
|
306
|
+
Owner.new("other", "other")
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# specs / rails_root / libdir から、パスの持ち主を最長前方一致で引く。
|
|
311
|
+
class OwnerResolver
|
|
312
|
+
def initialize(payload)
|
|
313
|
+
@pwd = payload["pwd_realpath"]
|
|
314
|
+
@cache = {}
|
|
315
|
+
@real = {}
|
|
316
|
+
gem_prefixes = []
|
|
317
|
+
(payload["specs"] || {}).each do |name, (_ver, path, ext)|
|
|
318
|
+
[path, ext].compact.each { |pth| prefixes(pth).each { |pf| gem_prefixes << [pf, name] } }
|
|
319
|
+
end
|
|
320
|
+
@gems = gem_prefixes.uniq.sort_by { |pf, _| -pf.size }
|
|
321
|
+
root = payload["rails_root"] || @pwd
|
|
322
|
+
@app = root && prefixes(root)
|
|
323
|
+
# アプリ自身が gem (gemspec / path: "." / engine の dummy app) のとき、gem の prefix がアプリ root を含んでしまう。
|
|
324
|
+
# その場合は app/config/lib/db 配下を gem より先に app とみなす。
|
|
325
|
+
@app_dirs = nil
|
|
326
|
+
if @app && @gems.any? { |pf, _| @app.any? { |a| under?(a, pf) } }
|
|
327
|
+
@app_dirs = @app.flat_map { |a| %w[app config lib db].map { |d| File.join(a, d) } }
|
|
328
|
+
end
|
|
329
|
+
@stdlib = Array(payload["libdir"]).flat_map { |d| prefixes(d) }.uniq
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def owner(path)
|
|
333
|
+
path = File.expand_path(path, @pwd || Dir.pwd) unless path.start_with?("/")
|
|
334
|
+
dir = File.dirname(path)
|
|
335
|
+
@cache[dir] ||= resolve_dir(dir)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
private
|
|
339
|
+
|
|
340
|
+
def prefixes(path)
|
|
341
|
+
[path, realpath(path)].compact.uniq
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def realpath(path)
|
|
345
|
+
@real[path] ||= begin
|
|
346
|
+
File.realpath(path)
|
|
347
|
+
rescue SystemCallError
|
|
348
|
+
path
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def under?(dir, prefix) = dir == prefix || dir.start_with?(prefix.end_with?("/") ? prefix : "#{prefix}/")
|
|
353
|
+
|
|
354
|
+
def resolve_dir(dir)
|
|
355
|
+
dirs = [dir, realpath(dir)].uniq
|
|
356
|
+
return Owner.new("app", "app") if @app_dirs&.any? { |pf| dirs.any? { |d| under?(d, pf) } }
|
|
357
|
+
# 最長一致: gem の full_gem_path / extension_dir
|
|
358
|
+
hit = @gems.find { |pf, _| dirs.any? { |d| under?(d, pf) } }
|
|
359
|
+
return Owner.new("gem", hit[1]) if hit
|
|
360
|
+
return Owner.new("app", "app") if @app&.any? { |pf| dirs.any? { |d| under?(d, pf) } }
|
|
361
|
+
return Owner.new("stdlib", "stdlib") if @stdlib.any? { |pf| dirs.any? { |d| under?(d, pf) } }
|
|
362
|
+
Owner.new("other", "other")
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|