rigortype 0.1.3 → 0.1.5

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.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +154 -33
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +47 -21
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +32 -3
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +1 -1
  9. data/lib/rigor/analysis/dependency_source_inference.rb +1 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +26 -6
  12. data/lib/rigor/analysis/result.rb +11 -3
  13. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  14. data/lib/rigor/analysis/run_stats.rb +193 -0
  15. data/lib/rigor/analysis/runner.rb +498 -12
  16. data/lib/rigor/analysis/worker_session.rb +327 -0
  17. data/lib/rigor/builtins/imported_refinements.rb +364 -55
  18. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  19. data/lib/rigor/cache/descriptor.rb +1 -1
  20. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  21. data/lib/rigor/cache/store.rb +39 -6
  22. data/lib/rigor/cli/diff_command.rb +1 -1
  23. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  24. data/lib/rigor/cli/type_of_command.rb +1 -1
  25. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  26. data/lib/rigor/cli/type_scan_report.rb +2 -2
  27. data/lib/rigor/cli.rb +61 -3
  28. data/lib/rigor/configuration/dependencies.rb +2 -2
  29. data/lib/rigor/configuration.rb +131 -6
  30. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  31. data/lib/rigor/environment/class_registry.rb +12 -3
  32. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  33. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  34. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  35. data/lib/rigor/environment/rbs_loader.rb +194 -6
  36. data/lib/rigor/environment/reflection.rb +152 -0
  37. data/lib/rigor/environment.rb +109 -6
  38. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  39. data/lib/rigor/flow_contribution/element.rb +1 -1
  40. data/lib/rigor/flow_contribution/fact.rb +1 -1
  41. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  42. data/lib/rigor/flow_contribution/merger.rb +3 -3
  43. data/lib/rigor/flow_contribution.rb +2 -2
  44. data/lib/rigor/inference/acceptance.rb +35 -1
  45. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  46. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  47. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  48. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  49. data/lib/rigor/inference/expression_typer.rb +77 -11
  50. data/lib/rigor/inference/fallback.rb +1 -1
  51. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  52. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  53. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -41
  54. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  55. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  56. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  57. data/lib/rigor/inference/method_dispatcher/method_folding.rb +135 -0
  58. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +7 -12
  59. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  60. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -44
  61. data/lib/rigor/inference/method_dispatcher.rb +274 -5
  62. data/lib/rigor/inference/method_parameter_binder.rb +22 -14
  63. data/lib/rigor/inference/narrowing.rb +129 -12
  64. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  65. data/lib/rigor/inference/scope_indexer.rb +14 -9
  66. data/lib/rigor/inference/statement_evaluator.rb +7 -7
  67. data/lib/rigor/inference/synthetic_method.rb +86 -0
  68. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  69. data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
  70. data/lib/rigor/plugin/blueprint.rb +60 -0
  71. data/lib/rigor/plugin/io_boundary.rb +0 -2
  72. data/lib/rigor/plugin/loader.rb +5 -3
  73. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  74. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  75. data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
  76. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  77. data/lib/rigor/plugin/macro.rb +31 -0
  78. data/lib/rigor/plugin/manifest.rb +102 -10
  79. data/lib/rigor/plugin/registry.rb +43 -2
  80. data/lib/rigor/plugin/services.rb +1 -1
  81. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  82. data/lib/rigor/plugin.rb +2 -0
  83. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  84. data/lib/rigor/rbs_extended.rb +131 -32
  85. data/lib/rigor/scope.rb +25 -8
  86. data/lib/rigor/sig_gen/classification.rb +36 -0
  87. data/lib/rigor/sig_gen/generator.rb +1048 -0
  88. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  89. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  90. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  91. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  92. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  93. data/lib/rigor/sig_gen/renderer.rb +157 -0
  94. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  95. data/lib/rigor/sig_gen/write_result.rb +48 -0
  96. data/lib/rigor/sig_gen/writer.rb +530 -0
  97. data/lib/rigor/sig_gen.rb +25 -0
  98. data/lib/rigor/trinary.rb +15 -11
  99. data/lib/rigor/type/bot.rb +6 -3
  100. data/lib/rigor/type/bound_method.rb +79 -0
  101. data/lib/rigor/type/combinator.rb +207 -3
  102. data/lib/rigor/type/constant.rb +13 -0
  103. data/lib/rigor/type/hash_shape.rb +0 -2
  104. data/lib/rigor/type/integer_range.rb +7 -7
  105. data/lib/rigor/type/refined.rb +18 -12
  106. data/lib/rigor/type/top.rb +4 -3
  107. data/lib/rigor/type/union.rb +20 -1
  108. data/lib/rigor/type.rb +1 -0
  109. data/lib/rigor/type_node/generic.rb +68 -0
  110. data/lib/rigor/type_node/identifier.rb +38 -0
  111. data/lib/rigor/type_node/indexed_access.rb +41 -0
  112. data/lib/rigor/type_node/integer_literal.rb +29 -0
  113. data/lib/rigor/type_node/name_scope.rb +52 -0
  114. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  115. data/lib/rigor/type_node/string_literal.rb +32 -0
  116. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  117. data/lib/rigor/type_node/union.rb +42 -0
  118. data/lib/rigor/type_node.rb +29 -0
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +2 -0
  121. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  122. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  123. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  124. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  125. data/sig/rigor/cli/diff_command.rbs +4 -0
  126. data/sig/rigor/cli/explain_command.rbs +4 -0
  127. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  128. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  129. data/sig/rigor/environment.rbs +8 -2
  130. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  131. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  132. data/sig/rigor/inference/builtins.rbs +2 -0
  133. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  134. data/sig/rigor/plugin/base.rbs +6 -0
  135. data/sig/rigor/plugin/blueprint.rbs +7 -0
  136. data/sig/rigor/plugin/fact_store.rbs +11 -0
  137. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  138. data/sig/rigor/plugin/load_error.rbs +6 -0
  139. data/sig/rigor/plugin/loader.rbs +20 -0
  140. data/sig/rigor/plugin/manifest.rbs +9 -0
  141. data/sig/rigor/plugin/registry.rbs +16 -0
  142. data/sig/rigor/plugin/services.rbs +3 -0
  143. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  144. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  145. data/sig/rigor/plugin.rbs +8 -0
  146. data/sig/rigor/scope.rbs +4 -2
  147. data/sig/rigor/type.rbs +28 -6
  148. data/sig/rigor.rbs +35 -2
  149. metadata +90 -1
@@ -24,10 +24,8 @@ module Rigor
24
24
  # ADR-2 § "Plugin Diagnostic Provenance") let consumers
25
25
  # distinguish where a diagnostic originated without committing
26
26
  # to the plugin API itself.
27
- # rubocop:disable Metrics/ParameterLists
28
27
  def initialize(path:, line:, column:, message:, severity: :error, rule: nil,
29
28
  source_family: DEFAULT_SOURCE_FAMILY)
30
- # rubocop:enable Metrics/ParameterLists
31
29
  @path = path
32
30
  @line = line
33
31
  @column = column
@@ -18,7 +18,7 @@ module Rigor
18
18
  relational
19
19
  ].freeze
20
20
 
21
- Target = Data.define(:kind, :name) do
21
+ class Target < Data.define(:kind, :name)
22
22
  def self.local(name)
23
23
  new(kind: :local, name: name.to_sym)
24
24
  end
@@ -28,7 +28,7 @@ module Rigor
28
28
  end
29
29
  end
30
30
 
31
- Fact = Data.define(:bucket, :target, :predicate, :payload, :polarity, :stability) do
31
+ class Fact < Data.define(:bucket, :target, :predicate, :payload, :polarity, :stability)
32
32
  def initialize(bucket:, target:, predicate:, payload: nil, polarity: :positive, stability: :local_binding)
33
33
  bucket = bucket.to_sym
34
34
  raise ArgumentError, "unknown fact bucket #{bucket.inspect}" unless BUCKETS.include?(bucket)
@@ -47,9 +47,14 @@ module Rigor
47
47
  attr_reader :facts
48
48
 
49
49
  class << self
50
- def empty
51
- @empty ||= new
52
- end
50
+ # ADR-15 Phase 4b.x — return the eagerly-loaded
51
+ # singleton-class `@empty` ivar. Lazy `@empty ||= new`
52
+ # would write to a class/module ivar from non-main
53
+ # Ractors and trip `Ractor::IsolationError`. The
54
+ # `@empty = new.freeze` at module body below
55
+ # pre-populates the ivar on the main Ractor at load
56
+ # time.
57
+ attr_reader :empty
53
58
  end
54
59
 
55
60
  def initialize(facts: [])
@@ -125,9 +130,24 @@ module Rigor
125
130
  unique.freeze
126
131
  end
127
132
 
133
+ # `fact.target` is `Target | Array[Target]` per the carrier
134
+ # contract. Branching with an early return on the `Array`
135
+ # arm lets type narrowing collapse the post-return value to
136
+ # the bare `Target` case, so the wrapped tuple is `[Target]`
137
+ # and the union of return paths is exactly `Array[Target]`.
128
138
  def fact_targets(fact)
129
- Array(fact.target)
139
+ target = fact.target
140
+ return target if target.is_a?(Array)
141
+
142
+ [target]
130
143
  end
144
+
145
+ # ADR-15 Phase 4b.x — eager-load the singleton `@empty`
146
+ # on the main Ractor at module-load time. Workers then
147
+ # READ the populated ivar without ever attempting a
148
+ # class/module ivar WRITE (which non-main Ractors are
149
+ # forbidden from doing).
150
+ @empty = new.freeze
131
151
  end
132
152
  end
133
153
  end
@@ -3,10 +3,16 @@
3
3
  module Rigor
4
4
  module Analysis
5
5
  class Result
6
- attr_reader :diagnostics
6
+ attr_reader :diagnostics, :stats
7
7
 
8
- def initialize(diagnostics: [])
8
+ # @param stats [Rigor::Analysis::RunStats, nil] end-of-run
9
+ # telemetry (target file count, RBS class breakdown,
10
+ # wall + RSS) collected by the Runner. Nil when stats
11
+ # collection wasn't requested or wasn't applicable
12
+ # (early-exit paths like `validate_target_ruby` failure).
13
+ def initialize(diagnostics: [], stats: nil)
9
14
  @diagnostics = diagnostics
15
+ @stats = stats
10
16
  end
11
17
 
12
18
  def success?
@@ -18,11 +24,13 @@ module Rigor
18
24
  end
19
25
 
20
26
  def to_h
21
- {
27
+ hash = {
22
28
  "success" => success?,
23
29
  "error_count" => error_count,
24
30
  "diagnostics" => diagnostics.map(&:to_h)
25
31
  }
32
+ hash["stats"] = @stats.to_h if @stats
33
+ hash
26
34
  end
27
35
  end
28
36
  end
@@ -31,8 +31,8 @@ module Rigor
31
31
  # from `Configuration::SeverityProfile::PROFILES`.
32
32
  # - `since` — first version the rule shipped in.
33
33
  module RuleCatalog # rubocop:disable Metrics/ModuleLength
34
- Entry = Data.define(:id, :summary, :fires_when, :does_not_fire_when,
35
- :suppression, :severity_authored, :severity_by_profile, :since) do
34
+ class Entry < Data.define(:id, :summary, :fires_when, :does_not_fire_when,
35
+ :suppression, :severity_authored, :severity_by_profile, :since)
36
36
  def aliases
37
37
  CheckRules::LEGACY_RULE_ALIASES.select { |_legacy, canonical| canonical == id }.keys
38
38
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ # End-of-run telemetry for the `rigor check` CLI's `--stats`
8
+ # output. Captures four cheap-to-measure groups:
9
+ #
10
+ # - **Check targets** — the Ruby files the analyser actually
11
+ # walks for diagnostics (`expand_paths` output).
12
+ # - **Type universe** — RBS class/module declarations the
13
+ # analyser had visibility of, broken down by source:
14
+ # `project_sig` (declarations whose source file lives under
15
+ # the configured `signature_paths`) vs `bundled` (RBS core,
16
+ # stdlib libraries, gem-bundled RBS — everything outside
17
+ # the project's own `sig/` tree).
18
+ # - **Gem source-walk** — the ADR-10
19
+ # `dependencies.source_inference` catalogue. Reports the
20
+ # class count and the number of opt-in gems contributing.
21
+ # - **Process** — wall-clock seconds + peak resident set size.
22
+ #
23
+ # The split between "check targets" and "type universe" makes
24
+ # explicit that the analyser's diagnostic surface is bounded
25
+ # by the user-controlled `paths:` configuration; the (typically
26
+ # much larger) RBS class universe is symbol-discovery, not a
27
+ # diagnostic surface.
28
+ #
29
+ # Stats collection is intentionally cheap: wall + RSS are
30
+ # single syscalls, target file count is already in
31
+ # `expand_paths`, gem source-walk uses
32
+ # `Index#class_to_gem.size`, and the RBS class breakdown
33
+ # walks `class_decl_paths` (a frozen `Hash<String, String>`
34
+ # populated once per environment by the RBS loader; ~1000-2000
35
+ # entries × one `String#start_with?`).
36
+ class RunStats
37
+ attr_reader :wall_seconds, :peak_rss_bytes,
38
+ :target_files,
39
+ :rbs_classes_total, :rbs_classes_project_sig, :rbs_classes_bundled,
40
+ :gem_walk_classes, :gem_walk_gems, :rbs_attribution_available
41
+
42
+ def initialize(wall_seconds:, peak_rss_bytes:, # rubocop:disable Metrics/ParameterLists
43
+ target_files:,
44
+ rbs_classes_total:, rbs_classes_project_sig:, rbs_classes_bundled:,
45
+ gem_walk_classes:, gem_walk_gems:,
46
+ rbs_attribution_available: true)
47
+ @wall_seconds = wall_seconds
48
+ @peak_rss_bytes = peak_rss_bytes
49
+ @target_files = target_files
50
+ @rbs_classes_total = rbs_classes_total
51
+ @rbs_classes_project_sig = rbs_classes_project_sig
52
+ @rbs_classes_bundled = rbs_classes_bundled
53
+ @gem_walk_classes = gem_walk_classes
54
+ @gem_walk_gems = gem_walk_gems
55
+ @rbs_attribution_available = rbs_attribution_available
56
+ freeze
57
+ end
58
+
59
+ # Reports the process's resident set size in bytes. Source
60
+ # ordering: `/proc/self/status` (Linux — reads `VmHWM:`,
61
+ # the peak RSS the kernel records) first; otherwise
62
+ # `ps -o rss= -p <pid>` (macOS / BSD — reports CURRENT
63
+ # RSS, the closest universally-available proxy). Returns
64
+ # nil when neither route works so the formatter can render
65
+ # `unavailable` instead of misleading zero.
66
+ def self.peak_rss_bytes
67
+ from_proc = read_vmhwm_from_proc
68
+ return from_proc unless from_proc.nil?
69
+
70
+ from_ps = read_rss_via_ps
71
+ return from_ps unless from_ps.nil?
72
+
73
+ nil
74
+ end
75
+
76
+ def self.read_vmhwm_from_proc
77
+ return nil unless File.readable?("/proc/self/status")
78
+
79
+ File.foreach("/proc/self/status") do |line|
80
+ next unless line.start_with?("VmHWM:")
81
+
82
+ kb_token = line.split.find { |token| token.match?(/\A\d+\z/) }
83
+ return Integer(kb_token) * 1024 if kb_token
84
+ end
85
+ nil
86
+ rescue StandardError
87
+ nil
88
+ end
89
+
90
+ def self.read_rss_via_ps
91
+ out = `ps -o rss= -p #{Process.pid} 2>/dev/null`.strip
92
+ return nil if out.empty?
93
+
94
+ Integer(out) * 1024
95
+ rescue StandardError
96
+ nil
97
+ end
98
+
99
+ # Source-attribution sentinel produced by `RBS::Environment`
100
+ # entries restored from a cached blob (Marshal-loaded
101
+ # `RBS::Environment` loses real file-path attribution; every
102
+ # buffer reports `"<cached>"`). When every entry carries
103
+ # this sentinel the partition_classes routine returns
104
+ # `[0, total]` AND `attribution_available: false`, which
105
+ # the format routine consumes to suppress the misleading
106
+ # breakdown row.
107
+ CACHED_SENTINEL = "<cached>"
108
+
109
+ # Computes `(project_sig, bundled)` counts from a frozen
110
+ # `Hash<class_name => source_path>` snapshot and the
111
+ # configured `signature_paths`. `project_sig` is the count
112
+ # of classes whose source path begins with any of the
113
+ # signature path prefixes (after expansion to absolute
114
+ # paths); `bundled` is the remainder.
115
+ def self.partition_classes(class_decl_paths:, signature_paths:)
116
+ prefixes = Array(signature_paths).map { |p| File.expand_path(p.to_s) }
117
+ return [0, class_decl_paths.size] if prefixes.empty?
118
+
119
+ project = 0
120
+ class_decl_paths.each_value do |path|
121
+ expanded = File.expand_path(path)
122
+ project += 1 if prefixes.any? { |prefix| expanded.start_with?("#{prefix}/") || expanded == prefix }
123
+ end
124
+ [project, class_decl_paths.size - project]
125
+ end
126
+
127
+ # True when at least one entry in `class_decl_paths` carries
128
+ # a real source file path (i.e. not the cached-sentinel
129
+ # marker). Used by callers to decide whether the
130
+ # `project_sig` / `bundled` split is meaningful.
131
+ def self.attribution_available?(class_decl_paths:)
132
+ return false if class_decl_paths.empty?
133
+
134
+ class_decl_paths.each_value.any? { |path| path != CACHED_SENTINEL }
135
+ end
136
+
137
+ # Writes a human-facing rendering of the stats to `out`
138
+ # (typically `$stderr` from the CLI). Format is intentionally
139
+ # plain text — JSON consumers should parse the structured
140
+ # output of `rigor check --format=json` and consult `stats`
141
+ # there.
142
+ def format(out, prefix: "")
143
+ out.puts("#{prefix}Check targets")
144
+ out.puts("#{prefix} Ruby source files: #{@target_files}")
145
+ out.puts("#{prefix}Type universe (symbol discovery; not analyzed for diagnostics)")
146
+ out.puts("#{prefix} RBS classes available: #{@rbs_classes_total}")
147
+ if @rbs_attribution_available
148
+ out.puts("#{prefix} project sig/: #{@rbs_classes_project_sig}")
149
+ out.puts("#{prefix} bundled (core+stdlib+gems): #{@rbs_classes_bundled}")
150
+ elsif @rbs_classes_total.positive?
151
+ out.puts("#{prefix} (source attribution unavailable on cache-hit runs; --no-cache surfaces it)")
152
+ end
153
+ if @gem_walk_gems.positive?
154
+ out.puts("#{prefix} Gem source-walk classes: #{@gem_walk_classes} " \
155
+ "(across #{@gem_walk_gems} #{@gem_walk_gems == 1 ? 'gem' : 'gems'} " \
156
+ "via dependencies.source_inference)")
157
+ end
158
+ out.puts("#{prefix}Process")
159
+ out.puts("#{prefix} Wall time: #{Kernel.format('%.2fs', @wall_seconds)}")
160
+ out.puts("#{prefix} Memory peak: #{format_bytes(@peak_rss_bytes)}")
161
+ end
162
+
163
+ def to_h
164
+ {
165
+ target_files: @target_files,
166
+ rbs_classes_total: @rbs_classes_total,
167
+ rbs_classes_project_sig: @rbs_classes_project_sig,
168
+ rbs_classes_bundled: @rbs_classes_bundled,
169
+ rbs_attribution_available: @rbs_attribution_available,
170
+ gem_walk_classes: @gem_walk_classes,
171
+ gem_walk_gems: @gem_walk_gems,
172
+ wall_seconds: @wall_seconds,
173
+ peak_rss_bytes: @peak_rss_bytes
174
+ }
175
+ end
176
+
177
+ private
178
+
179
+ def format_bytes(bytes)
180
+ return "unavailable" if bytes.nil?
181
+
182
+ units = %w[B KB MB GB TB]
183
+ size = bytes.to_f
184
+ index = 0
185
+ while size >= 1024 && index < units.size - 1
186
+ size /= 1024
187
+ index += 1
188
+ end
189
+ Kernel.format("%<size>.1f %<unit>s", size: size, unit: units[index])
190
+ end
191
+ end
192
+ end
193
+ end