diff --git a/CHANGELOG.md b/CHANGELOG.md index 618cf10..9c34104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # main ([unreleased](https://github.com/fastruby/rails_stats/compare/v2.1.0...main)) +* [FEATURE: Support component-based and Packwerk (packs) applications with a per-pack and Core breakdown](https://github.com/fastruby/rails_stats/issues/23) +* [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) * [CHORE: Improve the GH Test Workflow](https://github.com/fastruby/rails_stats/pull/35) * [BUGFIX: Explicitly set format as text for `Bundler::Stats::CLI` on `ConsoleFormatter`](https://github.com/fastruby/rails_stats/pull/43) diff --git a/README.md b/README.md index e94c4fb..ca3bdf2 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,56 @@ Directory: /path/to/app/ $ for dir in /path/to/many/engines/*/; do bundle exec rake stats[$dir]; done ``` +### Component-based and Packwerk (packs) applications + +RailsStats understands large Rails monoliths that are organized into +[packs](https://github.com/Shopify/packwerk) (via +[packs-rails](https://github.com/rubyatscale/packs-rails)) or +[components](https://cbra.info/) (component-based Rails). + +When packs or components are detected, `rake stats` automatically prints a +breakdown per pack/component, a `Core Application` section for everything that +lives in the main app, and a `Total (all packs)` section that aggregates the +whole monolith: + +```bash +$ bundle exec rake stats + +== Core Application == ++----------------------+---------+---------+---------+---------+---------+-----+-------+ +| Name | Files | Lines | LOC | Classes | Methods | M/C | LOC/M | ++----------------------+---------+---------+---------+---------+---------+-----+-------+ +| ... | ++----------------------+---------+---------+---------+---------+---------+-----+-------+ + +== Pack: packs/pack1 == ++----------------------+---------+---------+---------+---------+---------+-----+-------+ +| ... | ++----------------------+---------+---------+---------+---------+---------+-----+-------+ + +== Total (all packs) == ++----------------------+---------+---------+---------+---------+---------+-----+-------+ +| ... | ++----------------------+---------+---------+---------+---------+---------+-----+-------+ +``` + +A directory is treated as a pack when it contains a `package.yml` file (the +Packwerk marker) or when it lives directly under a `packs/` or `components/` +directory and holds any of the usual code folders (`app`, `lib`, `spec`, +`test`). Nested packs (`packs/a/packs/b`) are supported. + +You can also scope stats to a single pack, even from inside a booted Rails app: + +```bash +$ bundle exec rake stats[packs/pack1] +``` + +The same breakdown is available in JSON under a `"packs"` key: + +```bash +$ bundle exec rake stats[.,json] +``` + ### Within your Rails project You can also include it within your Rails application to _replace_ the default `rake stats` implementation. diff --git a/lib/rails_stats/all.rb b/lib/rails_stats/all.rb index 6894c1c..d664590 100644 --- a/lib/rails_stats/all.rb +++ b/lib/rails_stats/all.rb @@ -1,9 +1,17 @@ +module RailsStats + # Vendored / generated directory names we never descend into or treat as a + # pack. Shared by PackFinder (which builds IGNORED_PATH from it) and Util + # (which matches them by basename while walking the tree). + IGNORED_DIRS = %w[node_modules vendor tmp log .git].freeze +end + require 'rails_stats/stats_calculator' require 'rails_stats/stats_formatter' require 'rails_stats/json_formatter' require 'rails_stats/console_formatter' require 'rails_stats/inflector' require 'rails_stats/code_statistics_calculator' +require 'rails_stats/pack_finder' require 'rails_stats/util' require 'rails_stats/app_statistics' require 'rails_stats/test_statistics' diff --git a/lib/rails_stats/app_statistics.rb b/lib/rails_stats/app_statistics.rb index 05eaf80..bdedd3d 100644 --- a/lib/rails_stats/app_statistics.rb +++ b/lib/rails_stats/app_statistics.rb @@ -1,6 +1,6 @@ module RailsStats class AppStatistics - attr_reader :statistics, :total, :test + attr_reader :statistics, :total, :test, :directory def initialize(directory) @directories = [] diff --git a/lib/rails_stats/console_formatter.rb b/lib/rails_stats/console_formatter.rb index 83b56d0..a2d5cbd 100644 --- a/lib/rails_stats/console_formatter.rb +++ b/lib/rails_stats/console_formatter.rb @@ -5,16 +5,10 @@ class ConsoleFormatter < StatsFormatter def to_s Bundler::Stats::CLI.start(['--format', 'text']) - print_header - sorted_keys = @statistics.keys.sort - sorted_keys.each { |key| print_line(key, @statistics[key]) } - print_splitter - - if @grand_total - print_line("Code", @code_total) - print_line("Tests", @tests_total) - print_line("Total", @grand_total) - print_splitter + if calculator.packs? + print_grouped + else + print_flat end print_code_test_stats @@ -24,6 +18,44 @@ def to_s private + def print_flat + print_table(@statistics) + end + + def print_grouped + @statistics_by_group.each do |group, group_statistics| + puts "== #{group_label(group)} ==" + print_table(group_statistics) + puts "" + end + + puts "== #{grand_total_label} ==" + print_table(@statistics) + end + + def group_label(group) + group == RailsStats::StatsCalculator::CORE_GROUP ? "Core Application" : "Pack: #{group}" + end + + def grand_total_label + "Total (all packs)" + end + + # Prints a full table (header, one line per concept, and Code/Tests/Total + # totals) for the given {concept => CodeStatisticsCalculator} hash. + def print_table(statistics) + code, tests, grand = calculator.totals_for(statistics) + + print_header + statistics.keys.sort.each { |key| print_line(key, statistics[key]) } + print_splitter + + print_line("Code", code) + print_line("Tests", tests) + print_line("Total", grand) + print_splitter + end + def print_header print_splitter puts "| Name | Files | Lines | LOC | Classes | Methods | M/C | LOC/M |" diff --git a/lib/rails_stats/cucumber_statistics.rb b/lib/rails_stats/cucumber_statistics.rb index 2856073..2c78778 100644 --- a/lib/rails_stats/cucumber_statistics.rb +++ b/lib/rails_stats/cucumber_statistics.rb @@ -1,6 +1,6 @@ module RailsStats class CucumberStatistics - attr_reader :statistics, :total, :test + attr_reader :statistics, :total, :test, :directory def initialize(directory) @test = true diff --git a/lib/rails_stats/gem_statistics.rb b/lib/rails_stats/gem_statistics.rb index fbc706b..61887ad 100644 --- a/lib/rails_stats/gem_statistics.rb +++ b/lib/rails_stats/gem_statistics.rb @@ -1,6 +1,6 @@ module RailsStats class GemStatistics - attr_reader :statistics, :total, :test + attr_reader :statistics, :total, :test, :directory def initialize(directory) @test = false diff --git a/lib/rails_stats/json_formatter.rb b/lib/rails_stats/json_formatter.rb index cb1f1d2..afd17ad 100644 --- a/lib/rails_stats/json_formatter.rb +++ b/lib/rails_stats/json_formatter.rb @@ -17,6 +17,8 @@ def result @result << stat_hash("Tests", @tests_total).merge(code_test_hash) if @tests_total @result << stat_hash("Total", @grand_total).merge(code_test_hash) if @grand_total + @result << { "packs" => packs_breakdown } if calculator.packs? + @result << { "schema_stats" => schema_info } @result << { "polymorphic_stats" => print_polymorphic_stats } @@ -29,6 +31,22 @@ def to_s private + # Per-pack / per-component breakdown, keyed by group name ("Core" plus + # each pack's relative path). Each group lists its concept stats followed + # by Code/Tests/Total rows for that group. + def packs_breakdown + @statistics_by_group.each_with_object({}) do |(group, group_statistics), out| + code, tests, grand = calculator.totals_for(group_statistics) + + rows = group_statistics.map { |key, stats| stat_hash(key, stats) } + rows << stat_hash("Code", code).merge("total" => true) + rows << stat_hash("Tests", tests).merge("total" => true) + rows << stat_hash("Total", grand).merge("total" => true) + + out[group] = rows + end + end + def code_test_hash code = calculator.code_loc tests = calculator.test_loc diff --git a/lib/rails_stats/pack_finder.rb b/lib/rails_stats/pack_finder.rb new file mode 100644 index 0000000..e9ffbc7 --- /dev/null +++ b/lib/rails_stats/pack_finder.rb @@ -0,0 +1,67 @@ +module RailsStats + # Detects "packs" (packwerk / packs-rails) and "components" (component-based + # Rails, à la Stephan Hagemann) inside a Rails application so that stats can + # be reported per pack/component plus a "Core" group for everything else. + # + # A directory is considered a pack when either: + # + # * it contains a `package.yml` file (the packwerk marker), or + # * it is a direct child of a `packs/` or `components/` directory and holds + # any of the usual code folders (`app`, `lib`, `spec`, `test`). + # + # Nested packs (e.g. `packs/a/packs/b`) are supported. The root directory + # itself is never reported as a pack. + module PackFinder + extend self + + CODE_FOLDERS = %w[app lib spec test].freeze + PACK_CONTAINERS = %w[packs components].freeze + + # Path regex used to filter glob results to skip vendored/generated + # directories. Built from the shared RailsStats::IGNORED_DIRS list. + IGNORED_PATH = %r{/(#{IGNORED_DIRS.map { |dir| Regexp.escape(dir) }.join("|")})/}.freeze + + # Returns the absolute paths of every detected pack/component, sorted. + def find(root_directory) + root = File.absolute_path(root_directory) + packs = {} + + collect_packwerk_packs(root, packs) + collect_convention_packs(root, packs) + + packs.keys.sort + end + + private + + def collect_packwerk_packs(root, packs) + Dir.glob(File.join(root, "**", "package.yml")).each do |marker_path| + next if marker_path =~ IGNORED_PATH + + dir = File.absolute_path(File.dirname(marker_path)) + next if dir == root + + packs[dir] = true + end + end + + def collect_convention_packs(root, packs) + PACK_CONTAINERS.each do |container| + Dir.glob(File.join(root, "**", container, "*")).each do |path| + next if path =~ IGNORED_PATH + next unless File.directory?(path) + + dir = File.absolute_path(path) + next if dir == root + next unless code_folder?(dir) + + packs[dir] = true + end + end + end + + def code_folder?(dir) + CODE_FOLDERS.any? { |folder| File.directory?(File.join(dir, folder)) } + end + end +end diff --git a/lib/rails_stats/root_statistics.rb b/lib/rails_stats/root_statistics.rb index 35b5536..a8b9d79 100644 --- a/lib/rails_stats/root_statistics.rb +++ b/lib/rails_stats/root_statistics.rb @@ -1,6 +1,6 @@ module RailsStats class RootStatistics - attr_reader :statistics, :total, :test + attr_reader :statistics, :total, :test, :directory ROOT_FOLDERS = { "lib" => "Libraries", diff --git a/lib/rails_stats/spec_statistics.rb b/lib/rails_stats/spec_statistics.rb index f8edda3..1523233 100644 --- a/lib/rails_stats/spec_statistics.rb +++ b/lib/rails_stats/spec_statistics.rb @@ -1,6 +1,6 @@ module RailsStats class SpecStatistics - attr_reader :statistics, :total, :test + attr_reader :statistics, :total, :test, :directory SPEC_FOLDERS = ['controllers', 'features', diff --git a/lib/rails_stats/stats_calculator.rb b/lib/rails_stats/stats_calculator.rb index f8c283e..0c7ac25 100644 --- a/lib/rails_stats/stats_calculator.rb +++ b/lib/rails_stats/stats_calculator.rb @@ -8,12 +8,14 @@ class StatsCalculator 'assets'] def initialize(root_directory) - @root_directory = root_directory + @root_directory = File.absolute_path(root_directory) @schema_path = File.join(@root_directory, "db", "schema.rb") @structure_path = File.join(@root_directory, "db", "structure.sql") + @pack_roots = PackFinder.find(@root_directory) @key_concepts = calculate_key_concepts @projects = calculate_projects @statistics = calculate_statistics + @statistics_by_group = calculate_statistics_by_group @code_loc = calculate_code @test_loc = calculate_tests @schema = calculate_create_table_calls @@ -21,7 +23,31 @@ def initialize(root_directory) @files_total, @code_total, @tests_total, @grand_total = calculate_totals end - attr_reader :code_loc, :code_total, :files_total, :grand_total, :statistics, :test_loc, :tests_total, :schema, :schema_path, :structure_path, :polymorphic + 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 + + CORE_GROUP = "Core".freeze + + # Returns true when the application is split into packs/components, i.e. + # there is at least one group beyond the core application. + def packs? + @pack_roots.any? + end + + # Given a {concept => CodeStatisticsCalculator} hash (such as one of the + # values in +statistics_by_group+ or the flat +statistics+), returns the + # [code, tests, grand] totals for that group. + def totals_for(group_statistics) + code = CodeStatisticsCalculator.new + tests = CodeStatisticsCalculator.new + grand = CodeStatisticsCalculator.new + + group_statistics.each_value do |stats| + grand.add(stats) + stats.test ? tests.add(stats) : code.add(stats) + end + + [code, tests, grand] + end private @@ -83,7 +109,23 @@ def calculate_test_projects end def calculate_root_projects - [RootStatistics.new(@root_directory)] + projects = [RootStatistics.new(@root_directory)] + projects + calculate_pack_root_projects + end + + # Packs that are not self-contained gems/engines (no gemspec) still have + # a `lib` (and sometimes `config`) directory that would otherwise be + # missed, since only the root RootStatistics scans the top-level lib. + # Gems are already covered by GemStatistics, so skip them here to avoid + # double-counting. + def calculate_pack_root_projects + @pack_roots.reject { |pack_root| gem_pack?(pack_root) }.collect do |pack_root| + RootStatistics.new(pack_root) + end + end + + def gem_pack?(pack_root) + Dir[File.join(pack_root, "*.gemspec")].any? end def calculate_cucumber_projects @@ -104,6 +146,62 @@ def calculate_statistics out end + # Same aggregation as +calculate_statistics+, but keeps each project's + # numbers grouped by the pack/component it belongs to (or "Core"). The + # returned hash is ordered with "Core" first followed by packs sorted by + # their relative path. + def calculate_statistics_by_group + grouped = {} + @projects.each do |project| + group = group_key_for(project.directory) + grouped[group] ||= {} + project.statistics.each do |key, stats| + grouped[group][key] ||= CodeStatisticsCalculator.new(project.test) + grouped[group][key].add(stats) + end + end + + # Drop empty concepts so a pack with, say, only models does not list a + # bunch of zero-line rows. + grouped.each_value do |group_statistics| + group_statistics.delete_if { |_key, stats| stats.lines == 0 } + end + grouped.delete_if { |_group, group_statistics| group_statistics.empty? } + + sort_groups(grouped) + end + + def sort_groups(grouped) + ordered = {} + ordered[CORE_GROUP] = grouped[CORE_GROUP] if grouped.key?(CORE_GROUP) + grouped.keys.reject { |key| key == CORE_GROUP }.sort.each do |key| + ordered[key] = grouped[key] + end + ordered + end + + # Maps a project directory to its group: the relative path of the pack it + # lives in, or "Core" when it belongs to the main application. + def group_key_for(directory) + pack_root = pack_root_for(directory) + return CORE_GROUP unless pack_root + + relative = pack_root.sub(/\A#{Regexp.escape(@root_directory)}\/?/, "") + relative.empty? ? CORE_GROUP : relative + end + + # Returns the most specific (longest matching) pack root that contains the + # given directory, or nil when it is not part of any pack. + def pack_root_for(directory) + directory = File.absolute_path(directory.to_s) + best = nil + @pack_roots.each do |pack_root| + next unless directory == pack_root || directory.start_with?("#{pack_root}/") + best = pack_root if best.nil? || pack_root.length > best.length + end + best + end + def calculate_totals files_total = @statistics.sum do |k,value| value.files_total diff --git a/lib/rails_stats/stats_formatter.rb b/lib/rails_stats/stats_formatter.rb index f03760a..0e386bf 100644 --- a/lib/rails_stats/stats_formatter.rb +++ b/lib/rails_stats/stats_formatter.rb @@ -3,6 +3,7 @@ class StatsFormatter def initialize(calculator, opts = {}) @calculator = calculator @statistics = calculator.statistics + @statistics_by_group = calculator.statistics_by_group @code_total = calculator.code_total @tests_total = calculator.tests_total @grand_total = calculator.grand_total diff --git a/lib/rails_stats/tasks.rb b/lib/rails_stats/tasks.rb index 067aff5..df3a9ba 100644 --- a/lib/rails_stats/tasks.rb +++ b/lib/rails_stats/tasks.rb @@ -3,8 +3,11 @@ Rake::Task["stats"].clear # clear out normal one if there require 'rails_stats/all' + # Respect an explicitly given path even when running inside a booted Rails + # app. Only fall back to Rails.root when no path was provided. This is what + # makes `rake stats[packs/pack1]` work from inside the app (see issue #23). path = args[:path] - path = Rails.root.to_s if defined?(Rails) + path ||= Rails.root.to_s if defined?(Rails) fmt = args[:format] || "" raise "no path given for stats" unless path diff --git a/lib/rails_stats/test_statistics.rb b/lib/rails_stats/test_statistics.rb index 927a998..c7f57eb 100644 --- a/lib/rails_stats/test_statistics.rb +++ b/lib/rails_stats/test_statistics.rb @@ -1,6 +1,6 @@ module RailsStats class TestStatistics - attr_reader :statistics, :total, :test + attr_reader :statistics, :total, :test, :directory SPEC_FOLDERS = ['controllers', 'functional', diff --git a/lib/rails_stats/util.rb b/lib/rails_stats/util.rb index f9baf45..ad93fde 100644 --- a/lib/rails_stats/util.rb +++ b/lib/rails_stats/util.rb @@ -81,8 +81,16 @@ def calculate_directory_statistics(directory, pattern = /.*\.(rb|js|jsx|ts|tsx|c Dir.foreach(directory) do |file_name| path = "#{directory}/#{file_name}" - if File.directory?(path) && (/^\./ !~ file_name) + # Skip vendored / generated directories (e.g. a pack's + # app/webpack/node_modules). Pruning here avoids both the wasted walk + # and opening a directory that merely looks like a source file, such as + # node_modules/popper.js (which would raise Errno::EISDIR). + if File.directory?(path) + next if /^\./ =~ file_name + next if IGNORED_DIRS.include?(file_name) + stats.add(calculate_directory_statistics(path, pattern)) + next end next unless file_name =~ pattern diff --git a/test/fixtures/packwerk_app/app/controllers/core_controller.rb b/test/fixtures/packwerk_app/app/controllers/core_controller.rb new file mode 100644 index 0000000..de30130 --- /dev/null +++ b/test/fixtures/packwerk_app/app/controllers/core_controller.rb @@ -0,0 +1,4 @@ +class CoreController + def index + end +end diff --git a/test/fixtures/packwerk_app/app/models/core_model.rb b/test/fixtures/packwerk_app/app/models/core_model.rb new file mode 100644 index 0000000..304b2f4 --- /dev/null +++ b/test/fixtures/packwerk_app/app/models/core_model.rb @@ -0,0 +1,5 @@ +class CoreModel + def core_method + true + end +end diff --git a/test/fixtures/packwerk_app/components/billing/app/models/billing_model.rb b/test/fixtures/packwerk_app/components/billing/app/models/billing_model.rb new file mode 100644 index 0000000..e770e2a --- /dev/null +++ b/test/fixtures/packwerk_app/components/billing/app/models/billing_model.rb @@ -0,0 +1,4 @@ +class BillingModel + def charge + end +end diff --git a/test/fixtures/packwerk_app/components/billing/lib/billing.rb b/test/fixtures/packwerk_app/components/billing/lib/billing.rb new file mode 100644 index 0000000..127724a --- /dev/null +++ b/test/fixtures/packwerk_app/components/billing/lib/billing.rb @@ -0,0 +1,2 @@ +module Billing +end diff --git a/test/fixtures/packwerk_app/lib/core_lib.rb b/test/fixtures/packwerk_app/lib/core_lib.rb new file mode 100644 index 0000000..7d434eb --- /dev/null +++ b/test/fixtures/packwerk_app/lib/core_lib.rb @@ -0,0 +1,3 @@ +module CoreLib + CONST = 1 +end diff --git a/test/fixtures/packwerk_app/package.yml b/test/fixtures/packwerk_app/package.yml new file mode 100644 index 0000000..a04d956 --- /dev/null +++ b/test/fixtures/packwerk_app/package.yml @@ -0,0 +1 @@ +enforce_dependencies: true diff --git a/test/fixtures/packwerk_app/packs/pack1/app/controllers/pack1_controller.rb b/test/fixtures/packwerk_app/packs/pack1/app/controllers/pack1_controller.rb new file mode 100644 index 0000000..df67f90 --- /dev/null +++ b/test/fixtures/packwerk_app/packs/pack1/app/controllers/pack1_controller.rb @@ -0,0 +1,4 @@ +class Pack1Controller + def show + end +end diff --git a/test/fixtures/packwerk_app/packs/pack1/app/models/pack1_model.rb b/test/fixtures/packwerk_app/packs/pack1/app/models/pack1_model.rb new file mode 100644 index 0000000..db04aaf --- /dev/null +++ b/test/fixtures/packwerk_app/packs/pack1/app/models/pack1_model.rb @@ -0,0 +1,6 @@ +class Pack1Model + def m1 + end + def m2 + end +end diff --git a/test/fixtures/packwerk_app/packs/pack1/lib/pack1_lib.rb b/test/fixtures/packwerk_app/packs/pack1/lib/pack1_lib.rb new file mode 100644 index 0000000..9a5ee10 --- /dev/null +++ b/test/fixtures/packwerk_app/packs/pack1/lib/pack1_lib.rb @@ -0,0 +1,2 @@ +module Pack1Lib +end diff --git a/test/fixtures/packwerk_app/packs/pack1/package.yml b/test/fixtures/packwerk_app/packs/pack1/package.yml new file mode 100644 index 0000000..878f981 --- /dev/null +++ b/test/fixtures/packwerk_app/packs/pack1/package.yml @@ -0,0 +1,3 @@ +enforce_dependencies: true +dependencies: + - packs/pack2 diff --git a/test/fixtures/packwerk_app/packs/pack1/spec/models/pack1_model_spec.rb b/test/fixtures/packwerk_app/packs/pack1/spec/models/pack1_model_spec.rb new file mode 100644 index 0000000..61d1620 --- /dev/null +++ b/test/fixtures/packwerk_app/packs/pack1/spec/models/pack1_model_spec.rb @@ -0,0 +1,4 @@ +describe Pack1Model do + it "works" do + end +end diff --git a/test/fixtures/packwerk_app/packs/pack2/app/models/pack2_model.rb b/test/fixtures/packwerk_app/packs/pack2/app/models/pack2_model.rb new file mode 100644 index 0000000..d20cb3c --- /dev/null +++ b/test/fixtures/packwerk_app/packs/pack2/app/models/pack2_model.rb @@ -0,0 +1,4 @@ +class Pack2Model + def only + end +end diff --git a/test/fixtures/packwerk_app/packs/pack2/test/models/pack2_model_test.rb b/test/fixtures/packwerk_app/packs/pack2/test/models/pack2_model_test.rb new file mode 100644 index 0000000..a097925 --- /dev/null +++ b/test/fixtures/packwerk_app/packs/pack2/test/models/pack2_model_test.rb @@ -0,0 +1,4 @@ +class Pack2ModelTest + def test_it + end +end diff --git a/test/lib/rails_stats/console_formatter_test.rb b/test/lib/rails_stats/console_formatter_test.rb index 60f318a..e7f6e93 100644 --- a/test/lib/rails_stats/console_formatter_test.rb +++ b/test/lib/rails_stats/console_formatter_test.rb @@ -22,5 +22,27 @@ assert_equal ['--format', 'text'], received_args end + + it 'prints a section per pack plus a Core and a grand total for packwerk apps' do + root_directory = File.absolute_path('./test/fixtures/packwerk_app') + calculator = RailsStats::StatsCalculator.new(root_directory) + formatter = RailsStats::ConsoleFormatter.new(calculator) + + original = Bundler::Stats::CLI.method(:start) + Bundler::Stats::CLI.define_singleton_method(:start) { |args = []| nil } + + begin + output, _ = capture_io { formatter.to_s } + ensure + Bundler::Stats::CLI.singleton_class.remove_method(:start) + Bundler::Stats::CLI.define_singleton_method(:start, original) + end + + assert_includes output, '== Core Application ==' + assert_includes output, '== Pack: packs/pack1 ==' + assert_includes output, '== Pack: packs/pack2 ==' + assert_includes output, '== Pack: components/billing ==' + assert_includes output, '== Total (all packs) ==' + end end end diff --git a/test/lib/rails_stats/pack_finder_test.rb b/test/lib/rails_stats/pack_finder_test.rb new file mode 100644 index 0000000..00a4e51 --- /dev/null +++ b/test/lib/rails_stats/pack_finder_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "test_helper" + +describe RailsStats::PackFinder do + describe ".find" do + it "detects packwerk packs and component-based components" do + root = File.absolute_path("./test/fixtures/packwerk_app") + + packs = RailsStats::PackFinder.find(root).map { |path| path.sub("#{root}/", "") } + + assert_equal ["components/billing", "packs/pack1", "packs/pack2"], packs.sort + end + + it "does not treat the root package.yml as a pack" do + root = File.absolute_path("./test/fixtures/packwerk_app") + + packs = RailsStats::PackFinder.find(root) + + refute_includes packs, root + end + + it "returns an empty list for a plain Rails app" do + root = File.absolute_path("./test/dummy") + + assert_empty RailsStats::PackFinder.find(root) + end + end +end diff --git a/test/lib/rails_stats/stats_calculator_test.rb b/test/lib/rails_stats/stats_calculator_test.rb new file mode 100644 index 0000000..f69c0f3 --- /dev/null +++ b/test/lib/rails_stats/stats_calculator_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "test_helper" + +describe RailsStats::StatsCalculator do + describe "#statistics_by_group" do + before do + root = File.absolute_path("./test/fixtures/packwerk_app") + @calculator = RailsStats::StatsCalculator.new(root) + end + + it "reports that the app is split into packs" do + assert @calculator.packs? + end + + it "groups statistics per pack/component with Core first" do + groups = @calculator.statistics_by_group.keys + + assert_equal "Core", groups.first + assert_equal ["Core", "components/billing", "packs/pack1", "packs/pack2"], groups + end + + it "attributes each pack's files to its own group" do + by_group = @calculator.statistics_by_group + + assert_equal 1, by_group["packs/pack1"]["Models"].files_total + assert_equal 1, by_group["packs/pack1"]["Controllers"].files_total + assert_equal 1, by_group["packs/pack1"]["Model Tests"].files_total + assert_equal 1, by_group["packs/pack2"]["Models"].files_total + assert_equal 1, by_group["components/billing"]["Models"].files_total + end + + it "keeps core files in the Core group" do + core = @calculator.statistics_by_group["Core"] + + assert_equal 1, core["Models"].files_total + assert_equal 1, core["Controllers"].files_total + assert_equal 1, core["Libraries"].files_total + end + + it "includes pack lib directories that are not gems" do + assert_equal 1, @calculator.statistics_by_group["packs/pack1"]["Libraries"].files_total + end + + it "has a grand total that matches the sum of every group" do + _code, _tests, grand = @calculator.totals_for(@calculator.statistics) + + per_group_files = @calculator.statistics_by_group.sum do |_group, stats| + _c, _t, group_grand = @calculator.totals_for(stats) + group_grand.files_total + end + + assert_equal grand.files_total, per_group_files + end + end + + describe "#packs?" do + it "is false for a plain Rails app" do + calculator = RailsStats::StatsCalculator.new(File.absolute_path("./test/dummy")) + + refute calculator.packs? + end + end +end diff --git a/test/lib/rails_stats/util_test.rb b/test/lib/rails_stats/util_test.rb new file mode 100644 index 0000000..dc52f47 --- /dev/null +++ b/test/lib/rails_stats/util_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "test_helper" +require "tmpdir" +require "fileutils" + +describe RailsStats::Util do + describe ".calculate_directory_statistics" do + it "ignores vendored directories and never counts a directory that looks like a source file" do + Dir.mktmpdir do |dir| + # A real source file that should be counted. + File.write(File.join(dir, "real.rb"), "class Real\nend\n") + + # A directory whose name matches the source-file pattern. It must be + # walked (its contents counted) but never opened as a file itself, + # which previously raised Errno::EISDIR. + weird = File.join(dir, "weird.rb") + FileUtils.mkdir_p(weird) + File.write(File.join(weird, "inner.rb"), "class Inner\nend\n") + + # A vendored tree that must be skipped entirely, including a directory + # named like a JS file (e.g. node_modules/popper.js), the exact case + # that crashed on a packs app. + popper = File.join(dir, "node_modules", "popper.js") + FileUtils.mkdir_p(popper) + File.write(File.join(popper, "index.js"), "function noop() {}\n") + + stats = RailsStats::Util.calculate_directory_statistics(dir) + + # real.rb + weird.rb/inner.rb, nothing under node_modules. + assert_equal 2, stats.files_total + end + end + + it "skips every ignored directory name" do + RailsStats::IGNORED_DIRS.each do |ignored| + Dir.mktmpdir do |dir| + vendored = File.join(dir, ignored) + FileUtils.mkdir_p(vendored) + File.write(File.join(vendored, "ignored.rb"), "class Ignored\nend\n") + + stats = RailsStats::Util.calculate_directory_statistics(dir) + + assert_equal 0, stats.files_total, "expected #{ignored}/ to be skipped" + end + end + end + end +end