rails_stats 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -1
  3. data/README.md +95 -18
  4. data/bin/rails-stats +27 -0
  5. data/lib/rails_stats/all.rb +8 -0
  6. data/lib/rails_stats/app_statistics.rb +1 -1
  7. data/lib/rails_stats/console_formatter.rb +42 -10
  8. data/lib/rails_stats/cucumber_statistics.rb +1 -1
  9. data/lib/rails_stats/gem_statistics.rb +1 -1
  10. data/lib/rails_stats/json_formatter.rb +18 -0
  11. data/lib/rails_stats/pack_finder.rb +67 -0
  12. data/lib/rails_stats/root_statistics.rb +1 -1
  13. data/lib/rails_stats/spec_statistics.rb +1 -1
  14. data/lib/rails_stats/stats_calculator.rb +101 -3
  15. data/lib/rails_stats/stats_formatter.rb +1 -0
  16. data/lib/rails_stats/tasks.rb +4 -1
  17. data/lib/rails_stats/test_statistics.rb +1 -1
  18. data/lib/rails_stats/util.rb +9 -1
  19. data/lib/rails_stats/version.rb +1 -1
  20. data/rails_stats.gemspec +4 -3
  21. data/test/fixtures/packwerk_app/app/controllers/core_controller.rb +4 -0
  22. data/test/fixtures/packwerk_app/app/models/core_model.rb +5 -0
  23. data/test/fixtures/packwerk_app/components/billing/app/models/billing_model.rb +4 -0
  24. data/test/fixtures/packwerk_app/components/billing/lib/billing.rb +2 -0
  25. data/test/fixtures/packwerk_app/lib/core_lib.rb +3 -0
  26. data/test/fixtures/packwerk_app/package.yml +1 -0
  27. data/test/fixtures/packwerk_app/packs/pack1/app/controllers/pack1_controller.rb +4 -0
  28. data/test/fixtures/packwerk_app/packs/pack1/app/models/pack1_model.rb +6 -0
  29. data/test/fixtures/packwerk_app/packs/pack1/lib/pack1_lib.rb +2 -0
  30. data/test/fixtures/packwerk_app/packs/pack1/package.yml +3 -0
  31. data/test/fixtures/packwerk_app/packs/pack1/spec/models/pack1_model_spec.rb +4 -0
  32. data/test/fixtures/packwerk_app/packs/pack2/app/models/pack2_model.rb +4 -0
  33. data/test/fixtures/packwerk_app/packs/pack2/test/models/pack2_model_test.rb +4 -0
  34. data/test/lib/rails_stats/console_formatter_test.rb +22 -0
  35. data/test/lib/rails_stats/pack_finder_test.rb +29 -0
  36. data/test/lib/rails_stats/stats_calculator_test.rb +64 -0
  37. data/test/lib/rails_stats/util_test.rb +49 -0
  38. metadata +44 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a444e6b90de2ab6eead56780299fc2b2a24811f6318877007238eb45a4be0591
4
- data.tar.gz: c790a1e79d70ea1ab78476052bf27b71cc3491b63c8b7bd2a2d180e81a817aa0
3
+ metadata.gz: 6c0ec937a76e806cef21dd005c123dc0cea07bdf4f83d565ee8d141b3169dda2
4
+ data.tar.gz: e9d5aa5cf96dd02b9cd26247e2a41fa0aa44d812230176e1fd3af76e1a3bd593
5
5
  SHA512:
6
- metadata.gz: b3971dca0766fd9d81aa927823111c80d5de66fe6b70e20fbc620395d5e95f1a847f9b2c6ff09dc51611423a444e797cb1d373a5f87ad7df8b58f18ca68ce50d
7
- data.tar.gz: 3d250b31139d4abf61a1c835e0f4022c17b62e57d57646935548f3a2086b1712b42dfd9a69d7d447aeeff465e69842660332f75faff87eed58f6c2d27e0b024a
6
+ metadata.gz: fcf370eb0f981441c2ea4c7a4ae54027c10daa03be92aa23b1c0455d95b98b697477dc04b18d7f9afe16f9a6b1bbdd3d3642b5acfeb7d3dcf5f7bf1395163e5a
7
+ data.tar.gz: fb0d687f57af91b8bb48caf85a4c9bfbc05a49ed4fdc73f7bf4d521a70a1824a84956854af11238d278eccf785c38baeb762964c0b5eb3b5b4b49335730fc488
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
- # main ([unreleased](https://github.com/fastruby/rails_stats/compare/v2.1.0...main))
1
+ # main ([unreleased](https://github.com/fastruby/rails_stats/compare/v2.2.0...main))
2
2
 
3
+ * [FEATURE: ABC](LINK)
4
+
5
+ # v2.2.0 ([commits](https://github.com/fastruby/rails_stats/compare/v2.2.0...v2.1.0))
6
+
7
+ * [FEATURE: Add a `rails-stats` command line executable that accepts an absolute or relative directory](https://github.com/fastruby/rails_stats/pull/49)
8
+ * [FEATURE: Support component-based and Packwerk (packs) applications with a per-pack and Core breakdown](https://github.com/fastruby/rails_stats/issues/23)
9
+ * [BUGFIX: Respect an explicit path argument (e.g. `rake stats[packs/pack1]`) when running inside a booted Rails app](https://github.com/fastruby/rails_stats/issues/23)
3
10
  * [CHORE: Improve the GH Test Workflow](https://github.com/fastruby/rails_stats/pull/35)
4
11
  * [BUGFIX: Explicitly set format as text for `Bundler::Stats::CLI` on `ConsoleFormatter`](https://github.com/fastruby/rails_stats/pull/43)
5
12
 
data/README.md CHANGED
@@ -6,22 +6,62 @@ There were a few things missing to the included `rake stats`
6
6
 
7
7
  RailsStats mainly adds the ability to be run from outside the project in question. This can be helpful if the app you are interested in can not be booted for some reason.
8
8
 
9
- ### Run it outside Rails project
9
+ ### Run it as a command line tool
10
+
11
+ Once the gem is installed you can point the `rails-stats` executable at any
12
+ directory, without setting up a `Rakefile`. It accepts both absolute and
13
+ relative paths:
14
+
15
+ ```bash
16
+ $ rails-stats /path/to/app
17
+ $ rails-stats path/to/app
18
+ ```
10
19
 
11
- You will need a `Rakefile` in the directory where you call `rake` and you will
12
- need to require `rails_stats`:
20
+ When no path is given it defaults to the current directory:
13
21
 
14
- ```ruby
15
- # Rakefile
16
- require "rails_stats"
22
+ ```bash
23
+ $ rails-stats
24
+ ```
25
+
26
+ You can also request JSON output by passing `json` as a second argument:
27
+
28
+ ```bash
29
+ $ rails-stats path/to/app json
17
30
  ```
18
31
 
19
- Then you can call it:
32
+ #### Calling it through `rake`
33
+
34
+ If you prefer `rake`, there are two ways to reach the same code:
35
+
36
+ - **From any directory**, add a `Rakefile` that requires `rails_stats` and call
37
+ the task with a path:
38
+
39
+ ```ruby
40
+ # Rakefile
41
+ require "rails_stats"
42
+ ```
43
+
44
+ ```bash
45
+ $ rake stats\[/path/to/app/\]
46
+ ```
47
+
48
+ - **Inside your own Rails app**, add `rails_stats` to your `Gemfile` to
49
+ _replace_ the default `rake stats` implementation (you might need to
50
+ `require "rails_stats"` in your `Rakefile`), then run:
51
+
52
+ ```bash
53
+ $ bundle exec rake stats
54
+ ```
55
+
56
+ ### Run it outside Rails project
57
+
58
+ The quickest way is the `rails-stats` executable, which does not require a
59
+ `Rakefile`:
20
60
 
21
61
  ```bash
22
- $ rake stats\[/path/to/app/\]
62
+ $ rails-stats /path/to/app
23
63
 
24
- Directory: /path/to/app/
64
+ Directory: /path/to/app
25
65
 
26
66
  +-----------------------|------------|----------------+
27
67
  | Name | Total Deps | 1st Level Deps |
@@ -68,20 +108,57 @@ Directory: /path/to/app/
68
108
  ### Run it on many Rails engines
69
109
 
70
110
  ```bash
71
- $ for dir in /path/to/many/engines/*/; do bundle exec rake stats[$dir]; done
111
+ $ for dir in /path/to/many/engines/*/; do rails-stats "$dir"; done
72
112
  ```
73
113
 
74
- ### Within your Rails project
114
+ ### Component-based and Packwerk (packs) applications
75
115
 
76
- You can also include it within your Rails application to _replace_ the default `rake stats` implementation.
116
+ RailsStats understands large Rails monoliths that are organized into
117
+ [packs](https://github.com/Shopify/packwerk) (via
118
+ [packs-rails](https://github.com/rubyatscale/packs-rails)) or
119
+ [components](https://cbra.info/) (component-based Rails).
77
120
 
78
- Just add rails_stats to your Gemfile.
79
- Depending on your setup, you might need to `require rails_stats` in your Rakefile.
121
+ When packs or components are detected, `rails-stats` automatically prints a
122
+ breakdown per pack/component, a `Core Application` section for everything that
123
+ lives in the main app, and a `Total (all packs)` section that aggregates the
124
+ whole monolith:
125
+
126
+ ```bash
127
+ $ rails-stats /path/to/monolith
128
+
129
+ == Core Application ==
130
+ +----------------------+---------+---------+---------+---------+---------+-----+-------+
131
+ | Name | Files | Lines | LOC | Classes | Methods | M/C | LOC/M |
132
+ +----------------------+---------+---------+---------+---------+---------+-----+-------+
133
+ | ... |
134
+ +----------------------+---------+---------+---------+---------+---------+-----+-------+
135
+
136
+ == Pack: packs/pack1 ==
137
+ +----------------------+---------+---------+---------+---------+---------+-----+-------+
138
+ | ... |
139
+ +----------------------+---------+---------+---------+---------+---------+-----+-------+
140
+
141
+ == Total (all packs) ==
142
+ +----------------------+---------+---------+---------+---------+---------+-----+-------+
143
+ | ... |
144
+ +----------------------+---------+---------+---------+---------+---------+-----+-------+
145
+ ```
146
+
147
+ A directory is treated as a pack when it contains a `package.yml` file (the
148
+ Packwerk marker) or when it lives directly under a `packs/` or `components/`
149
+ directory and holds any of the usual code folders (`app`, `lib`, `spec`,
150
+ `test`). Nested packs (`packs/a/packs/b`) are supported.
151
+
152
+ You can also scope stats to a single pack:
153
+
154
+ ```bash
155
+ $ rails-stats packs/pack1
156
+ ```
80
157
 
81
- Then you'll be able to just run:
158
+ The same breakdown is available in JSON under a `"packs"` key:
82
159
 
83
160
  ```bash
84
- $ bundle exec rake stats
161
+ $ rails-stats . json
85
162
  ```
86
163
 
87
164
  ### Things it knows about
@@ -101,7 +178,7 @@ Here are some open source Rails projects and their output.
101
178
 
102
179
  ```bash
103
180
 
104
- $ bundle exec rake stats[/users/brian/examples/redmine/]
181
+ $ rails-stats /users/brian/examples/redmine/
105
182
 
106
183
  +-----------------------|------------|----------------+
107
184
  | Name | Total Deps | 1st Level Deps |
@@ -190,7 +267,7 @@ $ bundle exec rake stats[/users/brian/examples/redmine/]
190
267
  If you want to export the details using JSON, you can use this command:
191
268
 
192
269
  ```
193
- $ rake stats\[test/dummy,json\]
270
+ $ rails-stats test/dummy json
194
271
 
195
272
  Directory: /Users/etagwerker/Projects/redmine
196
273
 
data/bin/rails-stats ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Standalone CLI for running rails_stats against a directory without having to
5
+ # set up a Rakefile. Accepts both absolute and relative paths:
6
+ #
7
+ # rails-stats /path/to/app
8
+ # rails-stats path/to/app
9
+ # rails-stats # defaults to the current directory
10
+ # rails-stats path/to/app json
11
+ #
12
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
13
+
14
+ require "rails_stats/all"
15
+
16
+ path = ARGV[0] || "."
17
+ format = ARGV[1] || ""
18
+
19
+ root_directory = File.absolute_path(path)
20
+
21
+ unless File.directory?(root_directory)
22
+ warn "rails-stats: no such directory - #{path}"
23
+ exit 1
24
+ end
25
+
26
+ puts "\nDirectory: #{root_directory}\n\n"
27
+ RailsStats::CodeStatistics.new(root_directory, format: format).to_s
@@ -1,9 +1,17 @@
1
+ module RailsStats
2
+ # Vendored / generated directory names we never descend into or treat as a
3
+ # pack. Shared by PackFinder (which builds IGNORED_PATH from it) and Util
4
+ # (which matches them by basename while walking the tree).
5
+ IGNORED_DIRS = %w[node_modules vendor tmp log .git].freeze
6
+ end
7
+
1
8
  require 'rails_stats/stats_calculator'
2
9
  require 'rails_stats/stats_formatter'
3
10
  require 'rails_stats/json_formatter'
4
11
  require 'rails_stats/console_formatter'
5
12
  require 'rails_stats/inflector'
6
13
  require 'rails_stats/code_statistics_calculator'
14
+ require 'rails_stats/pack_finder'
7
15
  require 'rails_stats/util'
8
16
  require 'rails_stats/app_statistics'
9
17
  require 'rails_stats/test_statistics'
@@ -1,6 +1,6 @@
1
1
  module RailsStats
2
2
  class AppStatistics
3
- attr_reader :statistics, :total, :test
3
+ attr_reader :statistics, :total, :test, :directory
4
4
 
5
5
  def initialize(directory)
6
6
  @directories = []
@@ -5,16 +5,10 @@ module RailsStats
5
5
  def to_s
6
6
  Bundler::Stats::CLI.start(['--format', 'text'])
7
7
 
8
- print_header
9
- sorted_keys = @statistics.keys.sort
10
- sorted_keys.each { |key| print_line(key, @statistics[key]) }
11
- print_splitter
12
-
13
- if @grand_total
14
- print_line("Code", @code_total)
15
- print_line("Tests", @tests_total)
16
- print_line("Total", @grand_total)
17
- print_splitter
8
+ if calculator.packs?
9
+ print_grouped
10
+ else
11
+ print_flat
18
12
  end
19
13
 
20
14
  print_code_test_stats
@@ -24,6 +18,44 @@ module RailsStats
24
18
 
25
19
  private
26
20
 
21
+ def print_flat
22
+ print_table(@statistics)
23
+ end
24
+
25
+ def print_grouped
26
+ @statistics_by_group.each do |group, group_statistics|
27
+ puts "== #{group_label(group)} =="
28
+ print_table(group_statistics)
29
+ puts ""
30
+ end
31
+
32
+ puts "== #{grand_total_label} =="
33
+ print_table(@statistics)
34
+ end
35
+
36
+ def group_label(group)
37
+ group == RailsStats::StatsCalculator::CORE_GROUP ? "Core Application" : "Pack: #{group}"
38
+ end
39
+
40
+ def grand_total_label
41
+ "Total (all packs)"
42
+ end
43
+
44
+ # Prints a full table (header, one line per concept, and Code/Tests/Total
45
+ # totals) for the given {concept => CodeStatisticsCalculator} hash.
46
+ def print_table(statistics)
47
+ code, tests, grand = calculator.totals_for(statistics)
48
+
49
+ print_header
50
+ statistics.keys.sort.each { |key| print_line(key, statistics[key]) }
51
+ print_splitter
52
+
53
+ print_line("Code", code)
54
+ print_line("Tests", tests)
55
+ print_line("Total", grand)
56
+ print_splitter
57
+ end
58
+
27
59
  def print_header
28
60
  print_splitter
29
61
  puts "| Name | Files | Lines | LOC | Classes | Methods | M/C | LOC/M |"
@@ -1,6 +1,6 @@
1
1
  module RailsStats
2
2
  class CucumberStatistics
3
- attr_reader :statistics, :total, :test
3
+ attr_reader :statistics, :total, :test, :directory
4
4
 
5
5
  def initialize(directory)
6
6
  @test = true
@@ -1,6 +1,6 @@
1
1
  module RailsStats
2
2
  class GemStatistics
3
- attr_reader :statistics, :total, :test
3
+ attr_reader :statistics, :total, :test, :directory
4
4
 
5
5
  def initialize(directory)
6
6
  @test = false
@@ -17,6 +17,8 @@ module RailsStats
17
17
  @result << stat_hash("Tests", @tests_total).merge(code_test_hash) if @tests_total
18
18
  @result << stat_hash("Total", @grand_total).merge(code_test_hash) if @grand_total
19
19
 
20
+ @result << { "packs" => packs_breakdown } if calculator.packs?
21
+
20
22
  @result << { "schema_stats" => schema_info }
21
23
  @result << { "polymorphic_stats" => print_polymorphic_stats }
22
24
 
@@ -29,6 +31,22 @@ module RailsStats
29
31
 
30
32
  private
31
33
 
34
+ # Per-pack / per-component breakdown, keyed by group name ("Core" plus
35
+ # each pack's relative path). Each group lists its concept stats followed
36
+ # by Code/Tests/Total rows for that group.
37
+ def packs_breakdown
38
+ @statistics_by_group.each_with_object({}) do |(group, group_statistics), out|
39
+ code, tests, grand = calculator.totals_for(group_statistics)
40
+
41
+ rows = group_statistics.map { |key, stats| stat_hash(key, stats) }
42
+ rows << stat_hash("Code", code).merge("total" => true)
43
+ rows << stat_hash("Tests", tests).merge("total" => true)
44
+ rows << stat_hash("Total", grand).merge("total" => true)
45
+
46
+ out[group] = rows
47
+ end
48
+ end
49
+
32
50
  def code_test_hash
33
51
  code = calculator.code_loc
34
52
  tests = calculator.test_loc
@@ -0,0 +1,67 @@
1
+ module RailsStats
2
+ # Detects "packs" (packwerk / packs-rails) and "components" (component-based
3
+ # Rails, à la Stephan Hagemann) inside a Rails application so that stats can
4
+ # be reported per pack/component plus a "Core" group for everything else.
5
+ #
6
+ # A directory is considered a pack when either:
7
+ #
8
+ # * it contains a `package.yml` file (the packwerk marker), or
9
+ # * it is a direct child of a `packs/` or `components/` directory and holds
10
+ # any of the usual code folders (`app`, `lib`, `spec`, `test`).
11
+ #
12
+ # Nested packs (e.g. `packs/a/packs/b`) are supported. The root directory
13
+ # itself is never reported as a pack.
14
+ module PackFinder
15
+ extend self
16
+
17
+ CODE_FOLDERS = %w[app lib spec test].freeze
18
+ PACK_CONTAINERS = %w[packs components].freeze
19
+
20
+ # Path regex used to filter glob results to skip vendored/generated
21
+ # directories. Built from the shared RailsStats::IGNORED_DIRS list.
22
+ IGNORED_PATH = %r{/(#{IGNORED_DIRS.map { |dir| Regexp.escape(dir) }.join("|")})/}.freeze
23
+
24
+ # Returns the absolute paths of every detected pack/component, sorted.
25
+ def find(root_directory)
26
+ root = File.absolute_path(root_directory)
27
+ packs = {}
28
+
29
+ collect_packwerk_packs(root, packs)
30
+ collect_convention_packs(root, packs)
31
+
32
+ packs.keys.sort
33
+ end
34
+
35
+ private
36
+
37
+ def collect_packwerk_packs(root, packs)
38
+ Dir.glob(File.join(root, "**", "package.yml")).each do |marker_path|
39
+ next if marker_path =~ IGNORED_PATH
40
+
41
+ dir = File.absolute_path(File.dirname(marker_path))
42
+ next if dir == root
43
+
44
+ packs[dir] = true
45
+ end
46
+ end
47
+
48
+ def collect_convention_packs(root, packs)
49
+ PACK_CONTAINERS.each do |container|
50
+ Dir.glob(File.join(root, "**", container, "*")).each do |path|
51
+ next if path =~ IGNORED_PATH
52
+ next unless File.directory?(path)
53
+
54
+ dir = File.absolute_path(path)
55
+ next if dir == root
56
+ next unless code_folder?(dir)
57
+
58
+ packs[dir] = true
59
+ end
60
+ end
61
+ end
62
+
63
+ def code_folder?(dir)
64
+ CODE_FOLDERS.any? { |folder| File.directory?(File.join(dir, folder)) }
65
+ end
66
+ end
67
+ end
@@ -1,6 +1,6 @@
1
1
  module RailsStats
2
2
  class RootStatistics
3
- attr_reader :statistics, :total, :test
3
+ attr_reader :statistics, :total, :test, :directory
4
4
 
5
5
  ROOT_FOLDERS = {
6
6
  "lib" => "Libraries",
@@ -1,6 +1,6 @@
1
1
  module RailsStats
2
2
  class SpecStatistics
3
- attr_reader :statistics, :total, :test
3
+ attr_reader :statistics, :total, :test, :directory
4
4
 
5
5
  SPEC_FOLDERS = ['controllers',
6
6
  'features',
@@ -8,12 +8,14 @@ module RailsStats
8
8
  'assets']
9
9
 
10
10
  def initialize(root_directory)
11
- @root_directory = root_directory
11
+ @root_directory = File.absolute_path(root_directory)
12
12
  @schema_path = File.join(@root_directory, "db", "schema.rb")
13
13
  @structure_path = File.join(@root_directory, "db", "structure.sql")
14
+ @pack_roots = PackFinder.find(@root_directory)
14
15
  @key_concepts = calculate_key_concepts
15
16
  @projects = calculate_projects
16
17
  @statistics = calculate_statistics
18
+ @statistics_by_group = calculate_statistics_by_group
17
19
  @code_loc = calculate_code
18
20
  @test_loc = calculate_tests
19
21
  @schema = calculate_create_table_calls
@@ -21,7 +23,31 @@ module RailsStats
21
23
  @files_total, @code_total, @tests_total, @grand_total = calculate_totals
22
24
  end
23
25
 
24
- attr_reader :code_loc, :code_total, :files_total, :grand_total, :statistics, :test_loc, :tests_total, :schema, :schema_path, :structure_path, :polymorphic
26
+ attr_reader :code_loc, :code_total, :files_total, :grand_total, :statistics, :test_loc, :tests_total, :schema, :schema_path, :structure_path, :polymorphic, :pack_roots, :statistics_by_group
27
+
28
+ CORE_GROUP = "Core".freeze
29
+
30
+ # Returns true when the application is split into packs/components, i.e.
31
+ # there is at least one group beyond the core application.
32
+ def packs?
33
+ @pack_roots.any?
34
+ end
35
+
36
+ # Given a {concept => CodeStatisticsCalculator} hash (such as one of the
37
+ # values in +statistics_by_group+ or the flat +statistics+), returns the
38
+ # [code, tests, grand] totals for that group.
39
+ def totals_for(group_statistics)
40
+ code = CodeStatisticsCalculator.new
41
+ tests = CodeStatisticsCalculator.new
42
+ grand = CodeStatisticsCalculator.new
43
+
44
+ group_statistics.each_value do |stats|
45
+ grand.add(stats)
46
+ stats.test ? tests.add(stats) : code.add(stats)
47
+ end
48
+
49
+ [code, tests, grand]
50
+ end
25
51
 
26
52
  private
27
53
 
@@ -83,7 +109,23 @@ module RailsStats
83
109
  end
84
110
 
85
111
  def calculate_root_projects
86
- [RootStatistics.new(@root_directory)]
112
+ projects = [RootStatistics.new(@root_directory)]
113
+ projects + calculate_pack_root_projects
114
+ end
115
+
116
+ # Packs that are not self-contained gems/engines (no gemspec) still have
117
+ # a `lib` (and sometimes `config`) directory that would otherwise be
118
+ # missed, since only the root RootStatistics scans the top-level lib.
119
+ # Gems are already covered by GemStatistics, so skip them here to avoid
120
+ # double-counting.
121
+ def calculate_pack_root_projects
122
+ @pack_roots.reject { |pack_root| gem_pack?(pack_root) }.collect do |pack_root|
123
+ RootStatistics.new(pack_root)
124
+ end
125
+ end
126
+
127
+ def gem_pack?(pack_root)
128
+ Dir[File.join(pack_root, "*.gemspec")].any?
87
129
  end
88
130
 
89
131
  def calculate_cucumber_projects
@@ -104,6 +146,62 @@ module RailsStats
104
146
  out
105
147
  end
106
148
 
149
+ # Same aggregation as +calculate_statistics+, but keeps each project's
150
+ # numbers grouped by the pack/component it belongs to (or "Core"). The
151
+ # returned hash is ordered with "Core" first followed by packs sorted by
152
+ # their relative path.
153
+ def calculate_statistics_by_group
154
+ grouped = {}
155
+ @projects.each do |project|
156
+ group = group_key_for(project.directory)
157
+ grouped[group] ||= {}
158
+ project.statistics.each do |key, stats|
159
+ grouped[group][key] ||= CodeStatisticsCalculator.new(project.test)
160
+ grouped[group][key].add(stats)
161
+ end
162
+ end
163
+
164
+ # Drop empty concepts so a pack with, say, only models does not list a
165
+ # bunch of zero-line rows.
166
+ grouped.each_value do |group_statistics|
167
+ group_statistics.delete_if { |_key, stats| stats.lines == 0 }
168
+ end
169
+ grouped.delete_if { |_group, group_statistics| group_statistics.empty? }
170
+
171
+ sort_groups(grouped)
172
+ end
173
+
174
+ def sort_groups(grouped)
175
+ ordered = {}
176
+ ordered[CORE_GROUP] = grouped[CORE_GROUP] if grouped.key?(CORE_GROUP)
177
+ grouped.keys.reject { |key| key == CORE_GROUP }.sort.each do |key|
178
+ ordered[key] = grouped[key]
179
+ end
180
+ ordered
181
+ end
182
+
183
+ # Maps a project directory to its group: the relative path of the pack it
184
+ # lives in, or "Core" when it belongs to the main application.
185
+ def group_key_for(directory)
186
+ pack_root = pack_root_for(directory)
187
+ return CORE_GROUP unless pack_root
188
+
189
+ relative = pack_root.sub(/\A#{Regexp.escape(@root_directory)}\/?/, "")
190
+ relative.empty? ? CORE_GROUP : relative
191
+ end
192
+
193
+ # Returns the most specific (longest matching) pack root that contains the
194
+ # given directory, or nil when it is not part of any pack.
195
+ def pack_root_for(directory)
196
+ directory = File.absolute_path(directory.to_s)
197
+ best = nil
198
+ @pack_roots.each do |pack_root|
199
+ next unless directory == pack_root || directory.start_with?("#{pack_root}/")
200
+ best = pack_root if best.nil? || pack_root.length > best.length
201
+ end
202
+ best
203
+ end
204
+
107
205
  def calculate_totals
108
206
  files_total = @statistics.sum do |k,value|
109
207
  value.files_total
@@ -3,6 +3,7 @@ module RailsStats
3
3
  def initialize(calculator, opts = {})
4
4
  @calculator = calculator
5
5
  @statistics = calculator.statistics
6
+ @statistics_by_group = calculator.statistics_by_group
6
7
  @code_total = calculator.code_total
7
8
  @tests_total = calculator.tests_total
8
9
  @grand_total = calculator.grand_total
@@ -3,8 +3,11 @@ task :stats, [:path, :format] do |t, args|
3
3
  Rake::Task["stats"].clear # clear out normal one if there
4
4
  require 'rails_stats/all'
5
5
 
6
+ # Respect an explicitly given path even when running inside a booted Rails
7
+ # app. Only fall back to Rails.root when no path was provided. This is what
8
+ # makes `rake stats[packs/pack1]` work from inside the app (see issue #23).
6
9
  path = args[:path]
7
- path = Rails.root.to_s if defined?(Rails)
10
+ path ||= Rails.root.to_s if defined?(Rails)
8
11
  fmt = args[:format] || ""
9
12
  raise "no path given for stats" unless path
10
13
 
@@ -1,6 +1,6 @@
1
1
  module RailsStats
2
2
  class TestStatistics
3
- attr_reader :statistics, :total, :test
3
+ attr_reader :statistics, :total, :test, :directory
4
4
 
5
5
  SPEC_FOLDERS = ['controllers',
6
6
  'functional',
@@ -81,8 +81,16 @@ module RailsStats
81
81
  Dir.foreach(directory) do |file_name|
82
82
  path = "#{directory}/#{file_name}"
83
83
 
84
- if File.directory?(path) && (/^\./ !~ file_name)
84
+ # Skip vendored / generated directories (e.g. a pack's
85
+ # app/webpack/node_modules). Pruning here avoids both the wasted walk
86
+ # and opening a directory that merely looks like a source file, such as
87
+ # node_modules/popper.js (which would raise Errno::EISDIR).
88
+ if File.directory?(path)
89
+ next if /^\./ =~ file_name
90
+ next if IGNORED_DIRS.include?(file_name)
91
+
85
92
  stats.add(calculate_directory_statistics(path, pattern))
93
+ next
86
94
  end
87
95
 
88
96
  next unless file_name =~ pattern
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsStats
4
- VERSION = "2.1.0"
4
+ VERSION = "2.2.0"
5
5
  end
data/rails_stats.gemspec CHANGED
@@ -6,15 +6,16 @@ require 'rails_stats/version'
6
6
  Gem::Specification.new do |spec|
7
7
  spec.name = "rails_stats"
8
8
  spec.version = RailsStats::VERSION
9
- spec.authors = ["Brian Leonard"]
10
- spec.email = ["brian@bleonard.com"]
9
+ spec.authors = ["Brian Leonard", "Ernesto Tagwerker"]
10
+ spec.email = ["brian@bleonard.com", "ernesto@ombulabs.com"]
11
11
  spec.summary = %q{Analyze a Rails project}
12
12
  spec.description = %q{Point it to a directory and see stuff about the app}
13
13
  spec.homepage = "https://github.com/fastruby/rails_stats"
14
14
  spec.license = "MIT"
15
15
 
16
16
  spec.files = `git ls-files -z`.split("\x0")
17
- spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.bindir = "bin"
18
+ spec.executables = ["rails-stats"]
18
19
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
20
  spec.require_paths = ["lib"]
20
21
 
@@ -0,0 +1,4 @@
1
+ class CoreController
2
+ def index
3
+ end
4
+ end
@@ -0,0 +1,5 @@
1
+ class CoreModel
2
+ def core_method
3
+ true
4
+ end
5
+ end
@@ -0,0 +1,4 @@
1
+ class BillingModel
2
+ def charge
3
+ end
4
+ end
@@ -0,0 +1,2 @@
1
+ module Billing
2
+ end
@@ -0,0 +1,3 @@
1
+ module CoreLib
2
+ CONST = 1
3
+ end
@@ -0,0 +1 @@
1
+ enforce_dependencies: true
@@ -0,0 +1,4 @@
1
+ class Pack1Controller
2
+ def show
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ class Pack1Model
2
+ def m1
3
+ end
4
+ def m2
5
+ end
6
+ end
@@ -0,0 +1,2 @@
1
+ module Pack1Lib
2
+ end
@@ -0,0 +1,3 @@
1
+ enforce_dependencies: true
2
+ dependencies:
3
+ - packs/pack2
@@ -0,0 +1,4 @@
1
+ describe Pack1Model do
2
+ it "works" do
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ class Pack2Model
2
+ def only
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ class Pack2ModelTest
2
+ def test_it
3
+ end
4
+ end
@@ -22,5 +22,27 @@ describe RailsStats::ConsoleFormatter do
22
22
 
23
23
  assert_equal ['--format', 'text'], received_args
24
24
  end
25
+
26
+ it 'prints a section per pack plus a Core and a grand total for packwerk apps' do
27
+ root_directory = File.absolute_path('./test/fixtures/packwerk_app')
28
+ calculator = RailsStats::StatsCalculator.new(root_directory)
29
+ formatter = RailsStats::ConsoleFormatter.new(calculator)
30
+
31
+ original = Bundler::Stats::CLI.method(:start)
32
+ Bundler::Stats::CLI.define_singleton_method(:start) { |args = []| nil }
33
+
34
+ begin
35
+ output, _ = capture_io { formatter.to_s }
36
+ ensure
37
+ Bundler::Stats::CLI.singleton_class.remove_method(:start)
38
+ Bundler::Stats::CLI.define_singleton_method(:start, original)
39
+ end
40
+
41
+ assert_includes output, '== Core Application =='
42
+ assert_includes output, '== Pack: packs/pack1 =='
43
+ assert_includes output, '== Pack: packs/pack2 =='
44
+ assert_includes output, '== Pack: components/billing =='
45
+ assert_includes output, '== Total (all packs) =='
46
+ end
25
47
  end
26
48
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ describe RailsStats::PackFinder do
6
+ describe ".find" do
7
+ it "detects packwerk packs and component-based components" do
8
+ root = File.absolute_path("./test/fixtures/packwerk_app")
9
+
10
+ packs = RailsStats::PackFinder.find(root).map { |path| path.sub("#{root}/", "") }
11
+
12
+ assert_equal ["components/billing", "packs/pack1", "packs/pack2"], packs.sort
13
+ end
14
+
15
+ it "does not treat the root package.yml as a pack" do
16
+ root = File.absolute_path("./test/fixtures/packwerk_app")
17
+
18
+ packs = RailsStats::PackFinder.find(root)
19
+
20
+ refute_includes packs, root
21
+ end
22
+
23
+ it "returns an empty list for a plain Rails app" do
24
+ root = File.absolute_path("./test/dummy")
25
+
26
+ assert_empty RailsStats::PackFinder.find(root)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ describe RailsStats::StatsCalculator do
6
+ describe "#statistics_by_group" do
7
+ before do
8
+ root = File.absolute_path("./test/fixtures/packwerk_app")
9
+ @calculator = RailsStats::StatsCalculator.new(root)
10
+ end
11
+
12
+ it "reports that the app is split into packs" do
13
+ assert @calculator.packs?
14
+ end
15
+
16
+ it "groups statistics per pack/component with Core first" do
17
+ groups = @calculator.statistics_by_group.keys
18
+
19
+ assert_equal "Core", groups.first
20
+ assert_equal ["Core", "components/billing", "packs/pack1", "packs/pack2"], groups
21
+ end
22
+
23
+ it "attributes each pack's files to its own group" do
24
+ by_group = @calculator.statistics_by_group
25
+
26
+ assert_equal 1, by_group["packs/pack1"]["Models"].files_total
27
+ assert_equal 1, by_group["packs/pack1"]["Controllers"].files_total
28
+ assert_equal 1, by_group["packs/pack1"]["Model Tests"].files_total
29
+ assert_equal 1, by_group["packs/pack2"]["Models"].files_total
30
+ assert_equal 1, by_group["components/billing"]["Models"].files_total
31
+ end
32
+
33
+ it "keeps core files in the Core group" do
34
+ core = @calculator.statistics_by_group["Core"]
35
+
36
+ assert_equal 1, core["Models"].files_total
37
+ assert_equal 1, core["Controllers"].files_total
38
+ assert_equal 1, core["Libraries"].files_total
39
+ end
40
+
41
+ it "includes pack lib directories that are not gems" do
42
+ assert_equal 1, @calculator.statistics_by_group["packs/pack1"]["Libraries"].files_total
43
+ end
44
+
45
+ it "has a grand total that matches the sum of every group" do
46
+ _code, _tests, grand = @calculator.totals_for(@calculator.statistics)
47
+
48
+ per_group_files = @calculator.statistics_by_group.sum do |_group, stats|
49
+ _c, _t, group_grand = @calculator.totals_for(stats)
50
+ group_grand.files_total
51
+ end
52
+
53
+ assert_equal grand.files_total, per_group_files
54
+ end
55
+ end
56
+
57
+ describe "#packs?" do
58
+ it "is false for a plain Rails app" do
59
+ calculator = RailsStats::StatsCalculator.new(File.absolute_path("./test/dummy"))
60
+
61
+ refute calculator.packs?
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+
7
+ describe RailsStats::Util do
8
+ describe ".calculate_directory_statistics" do
9
+ it "ignores vendored directories and never counts a directory that looks like a source file" do
10
+ Dir.mktmpdir do |dir|
11
+ # A real source file that should be counted.
12
+ File.write(File.join(dir, "real.rb"), "class Real\nend\n")
13
+
14
+ # A directory whose name matches the source-file pattern. It must be
15
+ # walked (its contents counted) but never opened as a file itself,
16
+ # which previously raised Errno::EISDIR.
17
+ weird = File.join(dir, "weird.rb")
18
+ FileUtils.mkdir_p(weird)
19
+ File.write(File.join(weird, "inner.rb"), "class Inner\nend\n")
20
+
21
+ # A vendored tree that must be skipped entirely, including a directory
22
+ # named like a JS file (e.g. node_modules/popper.js), the exact case
23
+ # that crashed on a packs app.
24
+ popper = File.join(dir, "node_modules", "popper.js")
25
+ FileUtils.mkdir_p(popper)
26
+ File.write(File.join(popper, "index.js"), "function noop() {}\n")
27
+
28
+ stats = RailsStats::Util.calculate_directory_statistics(dir)
29
+
30
+ # real.rb + weird.rb/inner.rb, nothing under node_modules.
31
+ assert_equal 2, stats.files_total
32
+ end
33
+ end
34
+
35
+ it "skips every ignored directory name" do
36
+ RailsStats::IGNORED_DIRS.each do |ignored|
37
+ Dir.mktmpdir do |dir|
38
+ vendored = File.join(dir, ignored)
39
+ FileUtils.mkdir_p(vendored)
40
+ File.write(File.join(vendored, "ignored.rb"), "class Ignored\nend\n")
41
+
42
+ stats = RailsStats::Util.calculate_directory_statistics(dir)
43
+
44
+ assert_equal 0, stats.files_total, "expected #{ignored}/ to be skipped"
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
metadata CHANGED
@@ -1,13 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_stats
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Leonard
8
+ - Ernesto Tagwerker
9
+ autorequire:
8
10
  bindir: bin
9
11
  cert_chain: []
10
- date: 2026-04-07 00:00:00.000000000 Z
12
+ date: 2026-07-12 00:00:00.000000000 Z
11
13
  dependencies:
12
14
  - !ruby/object:Gem::Dependency
13
15
  name: rake
@@ -40,7 +42,9 @@ dependencies:
40
42
  description: Point it to a directory and see stuff about the app
41
43
  email:
42
44
  - brian@bleonard.com
43
- executables: []
45
+ - ernesto@ombulabs.com
46
+ executables:
47
+ - rails-stats
44
48
  extensions: []
45
49
  extra_rdoc_files: []
46
50
  files:
@@ -52,6 +56,7 @@ files:
52
56
  - LICENSE.txt
53
57
  - README.md
54
58
  - Rakefile
59
+ - bin/rails-stats
55
60
  - codecov.yml
56
61
  - lib/rails_stats.rb
57
62
  - lib/rails_stats/all.rb
@@ -63,6 +68,7 @@ files:
63
68
  - lib/rails_stats/gem_statistics.rb
64
69
  - lib/rails_stats/inflector.rb
65
70
  - lib/rails_stats/json_formatter.rb
71
+ - lib/rails_stats/pack_finder.rb
66
72
  - lib/rails_stats/root_statistics.rb
67
73
  - lib/rails_stats/spec_statistics.rb
68
74
  - lib/rails_stats/stats_calculator.rb
@@ -137,14 +143,31 @@ files:
137
143
  - test/dummy/test/models/user_test.rb
138
144
  - test/dummy/test/support/test_helper.rb
139
145
  - test/fixtures/console-output.txt
146
+ - test/fixtures/packwerk_app/app/controllers/core_controller.rb
147
+ - test/fixtures/packwerk_app/app/models/core_model.rb
148
+ - test/fixtures/packwerk_app/components/billing/app/models/billing_model.rb
149
+ - test/fixtures/packwerk_app/components/billing/lib/billing.rb
150
+ - test/fixtures/packwerk_app/lib/core_lib.rb
151
+ - test/fixtures/packwerk_app/package.yml
152
+ - test/fixtures/packwerk_app/packs/pack1/app/controllers/pack1_controller.rb
153
+ - test/fixtures/packwerk_app/packs/pack1/app/models/pack1_model.rb
154
+ - test/fixtures/packwerk_app/packs/pack1/lib/pack1_lib.rb
155
+ - test/fixtures/packwerk_app/packs/pack1/package.yml
156
+ - test/fixtures/packwerk_app/packs/pack1/spec/models/pack1_model_spec.rb
157
+ - test/fixtures/packwerk_app/packs/pack2/app/models/pack2_model.rb
158
+ - test/fixtures/packwerk_app/packs/pack2/test/models/pack2_model_test.rb
140
159
  - test/lib/rails_stats/code_statistics_test.rb
141
160
  - test/lib/rails_stats/console_formatter_test.rb
142
161
  - test/lib/rails_stats/json_formatter_test.rb
162
+ - test/lib/rails_stats/pack_finder_test.rb
163
+ - test/lib/rails_stats/stats_calculator_test.rb
164
+ - test/lib/rails_stats/util_test.rb
143
165
  - test/test_helper.rb
144
166
  homepage: https://github.com/fastruby/rails_stats
145
167
  licenses:
146
168
  - MIT
147
169
  metadata: {}
170
+ post_install_message:
148
171
  rdoc_options: []
149
172
  require_paths:
150
173
  - lib
@@ -159,7 +182,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
159
182
  - !ruby/object:Gem::Version
160
183
  version: '0'
161
184
  requirements: []
162
- rubygems_version: 3.6.2
185
+ rubygems_version: 3.0.3.1
186
+ signing_key:
163
187
  specification_version: 4
164
188
  summary: Analyze a Rails project
165
189
  test_files:
@@ -227,7 +251,23 @@ test_files:
227
251
  - test/dummy/test/models/user_test.rb
228
252
  - test/dummy/test/support/test_helper.rb
229
253
  - test/fixtures/console-output.txt
254
+ - test/fixtures/packwerk_app/app/controllers/core_controller.rb
255
+ - test/fixtures/packwerk_app/app/models/core_model.rb
256
+ - test/fixtures/packwerk_app/components/billing/app/models/billing_model.rb
257
+ - test/fixtures/packwerk_app/components/billing/lib/billing.rb
258
+ - test/fixtures/packwerk_app/lib/core_lib.rb
259
+ - test/fixtures/packwerk_app/package.yml
260
+ - test/fixtures/packwerk_app/packs/pack1/app/controllers/pack1_controller.rb
261
+ - test/fixtures/packwerk_app/packs/pack1/app/models/pack1_model.rb
262
+ - test/fixtures/packwerk_app/packs/pack1/lib/pack1_lib.rb
263
+ - test/fixtures/packwerk_app/packs/pack1/package.yml
264
+ - test/fixtures/packwerk_app/packs/pack1/spec/models/pack1_model_spec.rb
265
+ - test/fixtures/packwerk_app/packs/pack2/app/models/pack2_model.rb
266
+ - test/fixtures/packwerk_app/packs/pack2/test/models/pack2_model_test.rb
230
267
  - test/lib/rails_stats/code_statistics_test.rb
231
268
  - test/lib/rails_stats/console_formatter_test.rb
232
269
  - test/lib/rails_stats/json_formatter_test.rb
270
+ - test/lib/rails_stats/pack_finder_test.rb
271
+ - test/lib/rails_stats/stats_calculator_test.rb
272
+ - test/lib/rails_stats/util_test.rb
233
273
  - test/test_helper.rb