libyear-rb 0.1.0 → 0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 780ffd231f0123d3d93b819886b7ec1bf90100c93a53b9a87eb3ff9028178392
4
- data.tar.gz: 7b4405cfbde73ffa056506087378ff45b794fe93c102933ccb202209e087c7db
3
+ metadata.gz: 199716d9e9d674a6b897277931b9cecaa3a762807da3341ff13bdd3b865d1587
4
+ data.tar.gz: e430a6dbb744e921f27a3218e1c31eaa5de9726beece1a07e7dfdc6890013d27
5
5
  SHA512:
6
- metadata.gz: 282f83a4874582c3d835cc8d9c5fb89eb7ac6e27c122fc4f8e82e9e10da007970e9806de21ca8e64d7ae1766cca222356f4f68737923845a940c0def7a9fb6df
7
- data.tar.gz: d3da23dc71125df5adeb55769624d82ead4642b97ac23c1931c355c264b4c803066758c625666c6a8b68f364d827a6029c2912508e45819ef3b6b575f0d2397a
6
+ metadata.gz: 43697c73f71080fceeec2b4c433f326b693c17e47a9b2496e714ae9e58b1ee74f7a0efbeec7dbd70ea329ed09646e81b248df0ba01dc7d1372ebd7845f0aab33
7
+ data.tar.gz: 1c7be9d76720da7a6992f7b0d028d0e6d7285209c80d657a8fba2611a831a7e1203265bff85cfa8d0e8aa6c399fc6f9aa487415fc39c555575391a67cccfed5c
@@ -2,8 +2,9 @@ name: Release Gem
2
2
 
3
3
  on:
4
4
  push:
5
- tags:
6
- - 'v*'
5
+ branches: main
6
+ paths: lib/libyear_rb/version.rb
7
+ workflow_dispatch:
7
8
 
8
9
  jobs:
9
10
  release:
data/CHANGELOG.md CHANGED
@@ -2,7 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.1.0] - 2025-01-09
5
+ ## [0.2.0] - 2026-08-19
6
+
7
+ ### Added
8
+
9
+ - The report can now be generated in JSON with the `--format json` flag
10
+ - The `--sort` flag orders the output by column values (default: `libyear,name`)
11
+ - The `--[no-]indirect` flag includes (default) or excludes indirect dependencies in the output
12
+ - A library API (`LibyearRb::Runner`) has been extracted for programmatic/scripting use
13
+
14
+ ### Changed
15
+
16
+ - All results are now sent to formatters, so the report can include both outdated and up-to-date gems
17
+ - Gem version metadata is fetched in parallel with per-host worker pools
18
+
19
+ ### Fixed
20
+
21
+ - When `XDG_CACHE_HOME` is set, the cache directory resolves as a `Pathname` instead of a `String`
22
+ - Fetch failures are rescued per dependency, so one error no longer crashes the report
23
+
24
+ ## [0.1.0] - 2026-01-09
6
25
 
7
26
  ### Added
8
27
 
data/README.md CHANGED
@@ -1,42 +1,37 @@
1
1
  # libyear-rb
2
2
 
3
- A simple measure of dependency freshness for Ruby apps.
4
-
5
- ```bash
6
- $ libyear-rb
7
- Gem Current Current Date Latest Latest Date Versions Days Years
8
- .................... ....... ............ ........ ........... ........ .... .....
9
- addressable 2.8.7 2024-06-21 2.8.8 2025-11-25 1 522 1.43
10
- bigdecimal 3.3.1 2025-10-09 4.0.1 2025-12-17 4 69 0.19
11
- rails 7.0.0 2021-12-15 7.2.0 2024-08-10 15 968 2.65
12
- System is 4.27 libyears behind
13
- Total releases behind: 20
14
- ```
15
-
16
- `libyear-rb` tells you how out-of-date your Gemfile.lock is, in libyears (the time between your installed version and the newest version).
17
-
18
- ## Features
19
-
20
- - Analyzes Gemfile.lock to measure dependency freshness
21
- - Reports libyears (time behind) and version distance (releases behind)
22
- - Supports any RubyGems.org-compatible gem server (private gem servers, mirrors, etc.)
23
- - Historical analysis with `--as-of` to see what your dependencies looked like at a specific date
24
- - Caches API responses to minimize network requests
3
+ A measure of dependency freshness for Gemfile.lock. Tells you how out-of-date your dependencies are, in [libyears](https://libyear.com/) by comparing the release date of the locked version to the latest available version.
25
4
 
26
5
  ## Installation
27
6
 
7
+ To use the CLI, install the gem:
8
+
28
9
  ```bash
29
10
  gem install libyear-rb
30
11
  ```
31
12
 
32
- Or add to your Gemfile:
13
+ ```bash
14
+ mise install gem:libyear-rb
15
+ ```
16
+
17
+ To use programmatically, add to your Gemfile:
33
18
 
34
19
  ```ruby
35
20
  gem "libyear-rb"
36
21
  ```
37
22
 
23
+ ```ruby
24
+ require "bundler/inline"
25
+ gemfile do
26
+ source "https://rubygems.org"
27
+ gem "libyear-rb"
28
+ end
29
+ ```
30
+
38
31
  ## Usage
39
32
 
33
+ ### As a CLI
34
+
40
35
  Run `libyear-rb` in a directory with a Gemfile.lock, or provide a path:
41
36
 
42
37
  ```bash
@@ -44,65 +39,118 @@ libyear-rb # Uses ./Gemfile.lock
44
39
  libyear-rb path/to/Gemfile.lock # Uses specified lockfile
45
40
  ```
46
41
 
47
- ### Options
42
+ ```
43
+ Gem Current Current Date Latest Latest Date Versions Days Years
44
+ .................... ....... ............ ........ ........... ........ .... .....
45
+ addressable 2.8.7 2024-06-21 2.8.8 2025-11-25 1 522 1.43
46
+ bigdecimal 3.3.1 2025-10-09 4.0.1 2025-12-17 4 69 0.19
47
+ rails 7.0.0 2021-12-15 7.2.0 2024-08-10 15 968 2.65
48
+ System is 4.27 libyears behind
49
+ Total releases behind: 20
50
+ ```
51
+
52
+ Options:
48
53
 
49
54
  ```
50
- --as-of DATE Analyze dependencies as of the given date (YYYY-MM-DD)
51
- --verbose Run with verbose logs
52
- --help Show help
53
- --version Show version
55
+ --as-of DATE Only consider versions released before DATE (YYYY-MM-DD)
56
+ --format FORMATTER Choose an output formatter (plaintext, json) (default: plaintext)
57
+ --sort SPEC Sort by comma-separated columns (default: libyear,name)
58
+ --[no-]indirect Include indirect dependencies in output (on by default)
59
+ --verbose Run with logs
60
+ --help Show help
61
+ --version Show version
62
+ ```
63
+
64
+ #### Sorting
65
+
66
+ The `--sort` flag accepts a comma-separated list of columns. Each column has a
67
+ fixed direction: `name` is ascending, while `latest-date`, `versions`, and
68
+ `libyear` are descending. Later columns are used to break ties.
69
+
70
+ ```bash
71
+ libyear-rb --sort versions,name # most releases behind first, then by name
72
+ libyear-rb --sort libyear # most libyears behind first
54
73
  ```
55
74
 
56
- ### Historical Analysis
75
+ #### Formatters
57
76
 
58
- You can analyze what your dependencies looked like at a specific point in time:
77
+ - `plaintext` (default) prints a human-readable table to stdout, listing only outdated gems.
78
+ - `json` — prints a machine-readable JSON document to stdout, listing every gem along with a summary.
59
79
 
60
80
  ```bash
61
- libyear-rb --as-of 2024-01-01
81
+ libyear-rb --format json
82
+ ```
83
+
84
+ ### As a Library
85
+
86
+ ```ruby
87
+ require "libyear_rb"
88
+
89
+ results = LibyearRb::Runner.new.run(File.read("Gemfile.lock"))
90
+
91
+ results.each do |r|
92
+ puts "#{r.name}: #{r.version_distance} versions, #{r.libyear_in_days} days behind"
93
+ end
94
+ ```
95
+
96
+ Configure the cache or logger before use:
97
+
98
+ ```ruby
99
+ LibyearRb.cache = LibyearRb::FileCache.new(skip_cache: true)
100
+ LibyearRb.logger = Logger.new($stderr)
62
101
  ```
63
102
 
64
103
  ## Private Gem Servers
65
104
 
66
- `libyear-rb` supports any gem server with a RubyGems.org-compatible API. It uses your configured gem sources from `~/.gemrc` or environment, so private gems hosted on servers like Gemfury, Artifactory, or self-hosted solutions work automatically.
105
+ `libyear-rb` supports any gem server with a RubyGems.org-compatible API. It uses your configured gem sources from `~/.gemrc` (`gem sources`) or environment, so private gems hosted on servers like Gemfury, Artifactory, or self-hosted solutions work automatically.
67
106
 
68
107
  ## Caching
69
108
 
70
109
  To reduce API requests and improve performance, `libyear-rb` caches gem version metadata for 24 hours.
71
110
 
72
- **Cache location:**
73
- - Uses `$XDG_CACHE_HOME/libyear-rb/` if `XDG_CACHE_HOME` is set
74
- - Otherwise defaults to `~/.cache/libyear-rb/`
75
-
76
- Cache files are organized by gem source host, so metadata from different gem servers is stored separately.
111
+ Cache location:
112
+ - Gem info is cached under `$XDG_CACHE_HOME/libyear-rb/` (or `~/.cache/libyear-rb/` if `XDG_CACHE_HOME` is not set), with a subdirectory for each gem host.
77
113
 
78
- **To skip the cache:**
114
+ To skip the cache:
79
115
  ```bash
80
116
  SKIP_CACHE=1 libyear-rb
81
117
  ```
82
118
 
83
- **To clear the cache:**
119
+ To clear the cache:
84
120
  ```bash
85
121
  rm -rf ~/.cache/libyear-rb
86
122
  ```
87
123
 
88
- ## Alternatives
124
+ ## Development
89
125
 
90
- Other Ruby tools for measuring dependency freshness:
126
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
91
127
 
92
- - [libyear-bundler](https://github.com/jaredbeck/libyear-bundler) - The original Ruby libyear implementation. Supports additional metrics like major/minor/patch version deltas and JSON output.
93
- - [bundler-audit](https://github.com/rubysec/bundler-audit) - Focuses on security vulnerabilities rather than freshness, but useful for dependency health.
94
- - [bundle outdated](https://bundler.io/man/bundle-outdated.1.html) - Built into Bundler. Shows outdated gems but doesn't calculate libyears.
128
+ ### Commands
95
129
 
96
- ## Development
130
+ ```bash
131
+ # Setup development environment
132
+ bin/setup
97
133
 
98
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt.
134
+ # Run tests
135
+ rake test
99
136
 
100
- To install this gem onto your local machine, run `bundle exec rake install`.
137
+ # Run linter
138
+ rake rubocop
139
+
140
+ # Install gem locally
141
+ bundle exec rake install
142
+ ```
101
143
 
102
144
  ## Acknowledgements
103
145
 
104
146
  The concept of libyear comes from the technical report "Measuring Dependency Freshness in Software Systems" by J. Cox, E. Bouwers, M. van Eekelen and J. Visser (ICSE 2015).
105
147
 
148
+ ## Alternatives
149
+
150
+ - [libyear-bundler](https://github.com/jaredbeck/libyear-bundler) - The original Ruby libyear implementation. Supports additional metrics like major/minor/patch version deltas and JSON output.
151
+ - [bundler-audit](https://github.com/rubysec/bundler-audit) - Focuses on security vulnerabilities rather than freshness.
152
+ - [bundle outdated](https://bundler.io/man/bundle-outdated.1.html) - Built into Bundler. Shows outdated gems but doesn't calculate libyears.
153
+
106
154
  ## License
107
155
 
108
156
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/exe/libyear-rb CHANGED
@@ -2,5 +2,9 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "libyear_rb"
5
+ require "libyear_rb/cli"
6
+
7
+ $stdout.sync = true
8
+ $stderr.sync = true
5
9
 
6
10
  LibyearRb::CLI.new(ARGV).run
@@ -8,38 +8,26 @@ module LibyearRb
8
8
  class CLI
9
9
  def initialize(argv = ARGV)
10
10
  @argv = argv.dup
11
- @options = {}
11
+ @options = {
12
+ formatter: Formatter.for("plaintext"),
13
+ indirect: true,
14
+ sorters: Sorter.parse(Sorter::DEFAULT_SPEC)
15
+ }
12
16
  parse_options!
13
17
  end
14
18
 
15
19
  def run
20
+ LibyearRb.logger = Logger.new($stderr) if @options[:verbose]
21
+
16
22
  lockfile_contents = read_lockfile
17
- logger = build_logger
18
-
19
- lockfile_parser = LockfileParser.new
20
- gem_info_fetcher = GemInfoFetcher.new
21
- dependency_analyzer = DependencyAnalyzer.new(logger: logger)
22
- reporter = PlaintextReporter.new
23
-
24
- runner = Runner.new(
25
- lockfile_parser: lockfile_parser,
26
- gem_info_fetcher: gem_info_fetcher,
27
- dependency_analyzer: dependency_analyzer,
28
- reporter: reporter,
29
- logger: logger
30
- )
31
-
32
- runner.run(lockfile_contents, as_of: @options[:as_of])
23
+ results = Runner.new.run(lockfile_contents, as_of: @options[:as_of])
24
+ sorted_results = Sorter.sort(results, @options.fetch(:sorters))
25
+
26
+ @options.fetch(:formatter).new(indirect: @options.fetch(:indirect)).generate(sorted_results)
33
27
  end
34
28
 
35
29
  private
36
30
 
37
- def build_logger
38
- return nil unless @options[:verbose]
39
-
40
- Logger.new($stderr)
41
- end
42
-
43
31
  def parse_options!
44
32
  OptionParser.new do |opts|
45
33
  opts.banner = "Usage: libyear-rb [Gemfile.lock] [options]"
@@ -63,6 +51,25 @@ module LibyearRb
63
51
  exit 1
64
52
  end
65
53
 
54
+ opts.on("-f", "--format FORMATTER", "Choose an output formatter (#{Formatter::NAMES.join(", ")}) (default: plaintext).") do |name|
55
+ @options[:formatter] = Formatter.for(name)
56
+ rescue ArgumentError => e
57
+ warn e.message
58
+ exit 1
59
+ end
60
+
61
+ opts.on("--sort SPEC",
62
+ "Sort by comma-separated columns (#{Sorter::COLUMNS.keys.join(", ")}) (default: #{Sorter::DEFAULT_SPEC}).") do |spec|
63
+ @options[:sorters] = Sorter.parse(spec)
64
+ rescue Sorter::InvalidSpec => e
65
+ warn e.message
66
+ exit 1
67
+ end
68
+
69
+ opts.on("--[no-]indirect", "Include indirect dependencies in output (on by default).") do |value|
70
+ @options[:indirect] = value
71
+ end
72
+
66
73
  opts.on("--verbose", "Run with verbose logs") do
67
74
  @options[:verbose] = true
68
75
  end
@@ -2,15 +2,11 @@
2
2
 
3
3
  module LibyearRb
4
4
  class DependencyAnalyzer
5
- def initialize(logger: nil)
6
- @logger = logger
7
- end
8
-
9
- def calculate_dependency_freshness(gem_name, gem_version, versions_metadata)
10
- current_version_info = versions_metadata.find { |version| version.number == gem_version }
5
+ def self.freshness(spec, versions_metadata)
6
+ current_version_info = versions_metadata.find { |version| version.number == spec.version }
11
7
 
12
8
  if current_version_info.nil?
13
- @logger&.warn("Skipping #{gem_name}: installed version #{gem_version} not found in metadata")
9
+ LibyearRb.logger.warn("Skipping #{spec.name}: installed version #{spec.version} not found in metadata")
14
10
  return
15
11
  end
16
12
 
@@ -23,10 +19,6 @@ module LibyearRb
23
19
  latest_version = latest_version_info.number
24
20
  current_version = current_version_info.number
25
21
 
26
- if latest_version == current_version
27
- return nil
28
- end
29
-
30
22
  latest_release_date = latest_version_info.created_at
31
23
  current_release_date = current_version_info.created_at
32
24
 
@@ -34,14 +26,14 @@ module LibyearRb
34
26
  libyear_in_days = [(latest_release_date - current_release_date).to_i, 0].max
35
27
 
36
28
  Result.new(
37
- name: gem_name,
38
- current_version: gem_version,
29
+ name: spec.name,
30
+ current_version: spec.version,
39
31
  current_version_release_date: current_release_date,
40
32
  latest_version: latest_version,
41
33
  latest_version_release_date: latest_release_date,
42
34
  version_distance: version_distance,
43
35
  libyear_in_days: libyear_in_days,
44
- is_direct: true
36
+ is_direct: spec.direct
45
37
  )
46
38
  end
47
39
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module LibyearRb
7
+ class FileCache
8
+ CACHE_EXPIRATION = 86_400 # 24 hours in seconds
9
+ CACHE_DIR_ROOT = ENV.fetch("XDG_CACHE_HOME", File.join(Dir.home, ".cache"))
10
+ CACHE_DIR_DEFAULT = File.join(CACHE_DIR_ROOT, "libyear-rb")
11
+ SKIP_CACHE = ENV.fetch("SKIP_CACHE", "0") == "1"
12
+
13
+ attr_reader :cache_dir
14
+
15
+ def initialize(cache_dir: CACHE_DIR_DEFAULT, skip_cache: SKIP_CACHE)
16
+ @cache_dir = Pathname.new(cache_dir)
17
+ @skip_cache = skip_cache
18
+ end
19
+
20
+ def fetch(remote_host, gem_name)
21
+ return yield if @skip_cache
22
+
23
+ path = cache_file_path(remote_host, gem_name)
24
+ if cache_valid?(path)
25
+ JSON.parse(path.read)
26
+ else
27
+ data = yield
28
+ return data if data.empty?
29
+
30
+ path.dirname.mkpath
31
+ path.write(JSON.dump(data))
32
+ path.utime(Time.now, Time.now)
33
+ data
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def cache_valid?(path)
40
+ return false unless path.exist?
41
+
42
+ cache_age = Time.now - path.mtime
43
+ cache_age < CACHE_EXPIRATION
44
+ end
45
+
46
+ def cache_file_path(remote_host, gem_name)
47
+ host_key = remote_host.gsub(/\W/, "_")
48
+ cache_dir.join(host_key, "#{gem_name}.json")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibyearRb
4
+ class Formatter
5
+ NAMES = %w[plaintext json].freeze
6
+
7
+ class << self
8
+ def for(name)
9
+ case name
10
+ when "plaintext"
11
+ require "libyear_rb/formatters/plaintext_formatter"
12
+ PlaintextFormatter
13
+ when "json"
14
+ require "libyear_rb/formatters/json_formatter"
15
+ JsonFormatter
16
+ else
17
+ raise ArgumentError, "Unknown formatter: #{name.inspect}. Available formatters: #{NAMES.join(", ")}."
18
+ end
19
+ end
20
+ end
21
+
22
+ def initialize(io: $stdout, indirect: true)
23
+ @io = io
24
+ @indirect = indirect
25
+ end
26
+
27
+ def generate(results)
28
+ raise NotImplementedError, "Subclasses must implement the generate method"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LibyearRb
6
+ class JsonFormatter < Formatter
7
+ def generate(results)
8
+ total_days = results.sum { |r| r.libyear_in_days || 0 }
9
+ total_versions = results.sum { |r| r.version_distance || 0 }
10
+
11
+ visible_results = @indirect ? results : results.select(&:is_direct)
12
+
13
+ payload = {
14
+ gems: visible_results.map { |result| gem_hash(result) },
15
+ summary: {
16
+ libyears_behind: (total_days / 365.0).round(2),
17
+ total_releases_behind: total_versions
18
+ }
19
+ }
20
+
21
+ @io.puts JSON.pretty_generate(payload)
22
+ end
23
+
24
+ private
25
+
26
+ def gem_hash(result)
27
+ {
28
+ name: result.name.to_s,
29
+ current_version: result.current_version&.to_s,
30
+ current_version_release_date: result.current_version_release_date&.strftime("%Y-%m-%d"),
31
+ latest_version: result.latest_version&.to_s,
32
+ latest_version_release_date: result.latest_version_release_date&.strftime("%Y-%m-%d"),
33
+ version_distance: result.version_distance,
34
+ libyear_in_days: result.libyear_in_days,
35
+ direct: result.is_direct
36
+ }
37
+ end
38
+ end
39
+ end
@@ -1,25 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LibyearRb
4
- class PlaintextReporter < Reporter
4
+ class PlaintextFormatter < Formatter
5
5
  UNKNOWN = "Unknown"
6
6
  COLUMN_BUFFER = 3
7
7
  HEADERS = ["Gem", "Current", "Current Date", "Latest", "Latest Date", "Versions", "Days", "Years"].freeze
8
8
 
9
9
  def generate(results)
10
- rows = results.sort_by(&:name).filter_map do |result|
11
- row_for(result) unless result.version_distance.zero?
10
+ total_days = results.sum { |r| r.libyear_in_days || 0 }
11
+ total_versions = results.sum { |r| r.version_distance || 0 }
12
+
13
+ visible_results = @indirect ? results : results.select(&:is_direct)
14
+ visible_results = visible_results.reject do |result|
15
+ result.version_distance.zero?
12
16
  end
13
17
 
14
- return if rows.empty?
18
+ return if visible_results.empty?
19
+
20
+ rows = visible_results.map { |result| row_for(result) }
15
21
 
16
22
  widths = calculate_widths(rows)
17
23
  @io.puts format_row(HEADERS, widths)
18
24
  @io.puts divider(widths)
19
25
  rows.each { |row| @io.puts format_row(row, widths) }
20
26
 
21
- total_days = results.sum { |r| r.libyear_in_days || 0 }
22
- total_versions = results.sum { |r| r.version_distance || 0 }
23
27
  @io.puts "System is %.2f libyears behind" % (total_days / 365.0)
24
28
  @io.puts "Total releases behind: #{total_versions}"
25
29
  end
@@ -1,80 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "gems"
4
3
  require "date"
4
+ require "gems"
5
5
  require "rubygems"
6
6
 
7
7
  module LibyearRb
8
8
  class GemInfoFetcher
9
- include GemInfoCacher
10
-
11
- RATE_LIMIT = 10 # https://guides.rubygems.org/rubygems-org-rate-limits/
12
- RATE_LIMIT_INTERVAL = 1.0 / RATE_LIMIT
13
-
14
- def initialize
15
- @gem_source_clients = {}
16
- @last_request_time = Hash.new { |hash, key| hash[key] = Time.now - RATE_LIMIT_INTERVAL }
9
+ def initialize(remote_host, rate_limiter: -> {})
10
+ @remote_host = remote_host
11
+ @rate_limiter = rate_limiter
12
+ source = Gem.sources.each_source.find { |s| s.uri.host == remote_host }
13
+ if source
14
+ uri = source.uri
15
+ @client = Gems::Client.new(
16
+ host: uri.origin + uri.request_uri,
17
+ username: uri.user,
18
+ password: uri.password
19
+ )
20
+ end
17
21
  end
18
22
 
19
- def gem_versions_for(gem_name, remote_host)
20
- client = client_for(remote_host)
21
- return [] unless client
23
+ def versions_for(gem_name)
24
+ return [] unless @client
22
25
 
23
- raw_versions = fetch_raw_versions(client, remote_host, gem_name)
24
- build_versions(gem_name, raw_versions)
26
+ raw = fetch_raw(gem_name)
27
+ parse_versions(gem_name, raw)
25
28
  end
26
29
 
27
30
  private
28
31
 
29
- def fetch_raw_versions(client, remote_host, gem_name)
30
- with_cache(remote_host, gem_name) do
31
- wait_for_rate_limit(remote_host)
32
-
33
- client.versions(gem_name)
32
+ def fetch_raw(gem_name)
33
+ LibyearRb.cache.fetch(@remote_host, gem_name) do
34
+ @rate_limiter.call
35
+ Array(@client.versions(gem_name))
34
36
  rescue Gems::GemError, Gems::NotFound
35
37
  []
36
38
  end
37
39
  end
38
40
 
39
- def build_versions(gem_name, raw_versions)
40
- Array(raw_versions)
41
- .map do |attributes|
42
- GemVersion.new(
43
- name: gem_name,
44
- number: Gem::Version.new(attributes["number"]),
45
- created_at: Date.parse(attributes["created_at"]),
46
- prerelease?: attributes["prerelease"]
47
- )
48
- end
49
- end
50
-
51
- def client_for(remote_host)
52
- return @gem_source_clients[remote_host] if @gem_source_clients.key?(remote_host)
53
-
54
- client = nil
55
- source = sources.find { |gem_source| gem_source.uri.host == remote_host }
56
-
57
- if source
58
- uri = source.uri
59
- client = Gems::Client.new(
60
- host: (uri.origin + uri.request_uri),
61
- username: uri.user,
62
- password: uri.password
41
+ def parse_versions(gem_name, raw_versions)
42
+ Array(raw_versions).map do |attrs|
43
+ GemVersion.new(
44
+ name: gem_name,
45
+ number: Gem::Version.new(attrs["number"]),
46
+ created_at: Date.parse(attrs["created_at"]),
47
+ prerelease?: attrs["prerelease"]
63
48
  )
64
49
  end
65
-
66
- @gem_source_clients[remote_host] = client
67
- end
68
-
69
- def sources
70
- @sources ||= Gem.sources.each_source.to_a
71
- end
72
-
73
- def wait_for_rate_limit(remote_host)
74
- now = Time.now
75
- elapsed = now - @last_request_time[remote_host]
76
- sleep(RATE_LIMIT_INTERVAL - elapsed) if elapsed < RATE_LIMIT_INTERVAL
77
- @last_request_time[remote_host] = Time.now
78
50
  end
79
51
  end
80
52
  end
@@ -25,6 +25,10 @@ module LibyearRb
25
25
  VERSION_LINE = /^ ?([^ ].+)$/
26
26
  BUNDLED_VERSION = /^ ?([^ ].+)$/
27
27
 
28
+ def self.parse(lockfile_content)
29
+ new.parse(lockfile_content)
30
+ end
31
+
28
32
  def parse(lockfile_content)
29
33
  lines = lockfile_content.lines(chomp: true)
30
34
 
@@ -58,6 +62,13 @@ module LibyearRb
58
62
  end
59
63
  end
60
64
 
65
+ direct_names = dependencies.map(&:name)
66
+ sources = sources.map do |source|
67
+ source.with(specs: source.specs.map { |spec|
68
+ spec.with(direct: direct_names.include?(spec.name))
69
+ })
70
+ end
71
+
61
72
  Lockfile.new(
62
73
  sources: sources,
63
74
  platforms: platforms,
@@ -3,7 +3,11 @@
3
3
  module LibyearRb
4
4
  Lockfile = Data.define(:sources, :platforms, :dependencies, :ruby_version, :bundled_with)
5
5
  Source = Data.define(:type, :remote, :revision, :specs, :options)
6
- Spec = Data.define(:name, :version, :dependencies)
6
+ Spec = Data.define(:name, :version, :dependencies, :direct) do
7
+ def initialize(name:, version:, dependencies: [], direct: false)
8
+ super
9
+ end
10
+ end
7
11
  Dependency = Data.define(:name, :version_requirements)
8
12
  Platform = Data.define(:name)
9
13
  RubyVersion = Data.define(:version, :engine, :patchlevel)
@@ -4,44 +4,80 @@ require "uri"
4
4
 
5
5
  module LibyearRb
6
6
  class Runner
7
- def initialize(lockfile_parser:, gem_info_fetcher:, dependency_analyzer:, reporter:, logger: nil)
8
- @lockfile_parser = lockfile_parser
9
- @gem_info_fetcher = gem_info_fetcher
10
- @dependency_analyzer = dependency_analyzer
11
- @reporter = reporter
12
- @logger = logger
13
- end
7
+ MAX_WORKERS_PER_HOST = 10
8
+ RATE_LIMIT = 10 # requests per second per host
14
9
 
15
10
  def run(lockfile_contents, as_of: nil)
16
- results = []
17
- lockfile = @lockfile_parser.parse(lockfile_contents)
18
- lockfile.sources.each do |source|
19
- unless source.type == :gem && !source.remote.nil?
20
- @logger&.warn("Skipping source #{source.type}: unsupported source type or missing remote")
21
- next
22
- end
11
+ specs_by_host = parse_specs(lockfile_contents)
12
+ threads = []
13
+ replenishers = []
23
14
 
24
- remote_host = URI.parse(source.remote).host
15
+ specs_by_host.each do |host, specs|
16
+ token_queue = Thread::SizedQueue.new(RATE_LIMIT)
17
+ rate_limiter = -> { token_queue.pop }
25
18
 
26
- source.specs.each do |spec|
27
- gem_name = spec.name
28
- gem_version = spec.version
29
- versions_metadata = @gem_info_fetcher.gem_versions_for(gem_name, remote_host)
30
- .reject { |version| as_of && version.created_at > as_of }
31
- .sort_by(&:number)
32
- .reverse
19
+ work_queue = Thread::Queue.new
20
+ specs.each { |spec| work_queue << spec }
21
+ work_queue.close
33
22
 
34
- if versions_metadata.empty?
35
- @logger&.warn("Skipping #{gem_name}: no version metadata from #{remote_host}")
36
- next
23
+ replenishers << Thread.new do
24
+ loop do
25
+ sleep(1.0 / RATE_LIMIT)
26
+ token_queue << :token
37
27
  end
28
+ end
38
29
 
39
- result = @dependency_analyzer.calculate_dependency_freshness(gem_name, gem_version, versions_metadata)
40
- results << result if result
30
+ [specs.size, MAX_WORKERS_PER_HOST].min.times do
31
+ threads << Thread.new do
32
+ fetcher = GemInfoFetcher.new(host, rate_limiter: rate_limiter)
33
+ thread_results = []
34
+ while (spec = work_queue.pop)
35
+ begin
36
+ versions = fetcher.versions_for(spec.name)
37
+ thread_results << [spec, versions]
38
+ rescue => e
39
+ LibyearRb.logger.error("Error fetching #{spec.name}: #{e.message}")
40
+ end
41
+ end
42
+ thread_results
43
+ end
41
44
  end
42
45
  end
43
- @reporter.generate(results)
44
- results
46
+
47
+ spec_versions = threads.flat_map(&:value)
48
+ spec_versions.filter_map { |spec, versions| analyze(spec, versions, as_of: as_of) }
49
+ rescue Exception # rubocop:disable Lint/RescueException
50
+ threads.each { |t| t.kill unless t == Thread.current }
51
+ raise
52
+ ensure
53
+ replenishers.each(&:kill)
54
+ end
55
+
56
+ private
57
+
58
+ def parse_specs(lockfile_contents)
59
+ lockfile = LockfileParser.parse(lockfile_contents)
60
+ lockfile.sources
61
+ .select { |source| source.type == :gem && source.remote }
62
+ .group_by { |source| URI.parse(source.remote).host }
63
+ .transform_values { |sources| sources.flat_map(&:specs).uniq(&:name) }
64
+ end
65
+
66
+ def analyze(spec, versions, as_of:)
67
+ filtered = versions
68
+ .reject { |v| as_of && v.created_at > as_of }
69
+ .sort_by(&:number)
70
+ .reverse
71
+
72
+ if filtered.empty?
73
+ LibyearRb.logger.warn("Skipping #{spec.name}: no version metadata")
74
+ return nil
75
+ end
76
+
77
+ DependencyAnalyzer.freshness(spec, filtered)
78
+ rescue => e
79
+ LibyearRb.logger.error("Error processing #{spec.name}: #{e.message}")
80
+ nil
45
81
  end
46
82
  end
47
83
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibyearRb
4
+ SortColumn = Data.define(:field, :direction) do
5
+ def compare(a, b)
6
+ av = a.public_send(field)
7
+ bv = b.public_send(field)
8
+ return 0 if av.nil? && bv.nil?
9
+ return 1 if av.nil?
10
+ return -1 if bv.nil?
11
+ (direction == :desc) ? bv <=> av : av <=> bv
12
+ end
13
+ end
14
+
15
+ module Sorter
16
+ DEFAULT_SPEC = "libyear,name"
17
+
18
+ COLUMNS = {
19
+ "name" => SortColumn.new(field: :name, direction: :asc),
20
+ "latest-date" => SortColumn.new(field: :latest_version_release_date, direction: :desc),
21
+ "versions" => SortColumn.new(field: :version_distance, direction: :desc),
22
+ "libyear" => SortColumn.new(field: :libyear_in_days, direction: :desc)
23
+ }.freeze
24
+
25
+ InvalidSpec = Class.new(ArgumentError)
26
+
27
+ class << self
28
+ def parse(spec)
29
+ spec.to_s.split(",").map do |part|
30
+ column = part.strip
31
+ unless COLUMNS.key?(column)
32
+ raise InvalidSpec, "Unknown sort column: #{column.inspect}. Available columns: #{COLUMNS.keys.join(", ")}."
33
+ end
34
+ COLUMNS.fetch(column)
35
+ end
36
+ end
37
+
38
+ def sort(results, sorters)
39
+ results.sort do |a, b|
40
+ cmp = 0
41
+ sorters.each do |sorter|
42
+ cmp = sorter.compare(a, b)
43
+ break unless cmp.zero?
44
+ end
45
+ cmp
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LibyearRb
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/libyear_rb.rb CHANGED
@@ -3,14 +3,27 @@
3
3
  require_relative "libyear_rb/version"
4
4
  require_relative "libyear_rb/models"
5
5
  require_relative "libyear_rb/lockfile_parser"
6
- require_relative "libyear_rb/gem_info_cacher"
6
+ require_relative "libyear_rb/file_cache"
7
7
  require_relative "libyear_rb/gem_info_fetcher"
8
8
  require_relative "libyear_rb/dependency_analyzer"
9
- require_relative "libyear_rb/reporters/reporter"
10
- require_relative "libyear_rb/reporters/plaintext_reporter"
9
+ require_relative "libyear_rb/formatters/formatter"
10
+ require_relative "libyear_rb/sorter"
11
11
  require_relative "libyear_rb/runner"
12
- require_relative "libyear_rb/cli"
12
+
13
+ require "logger"
13
14
 
14
15
  module LibyearRb
15
16
  class Error < StandardError; end
17
+
18
+ class << self
19
+ attr_writer :logger, :cache
20
+
21
+ def logger
22
+ @logger ||= Logger.new(File::NULL)
23
+ end
24
+
25
+ def cache
26
+ @cache ||= FileCache.new
27
+ end
28
+ end
16
29
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: libyear-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas Countz
@@ -14,14 +14,14 @@ dependencies:
14
14
  name: gems
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '1.3'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - "~>"
24
+ - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.3'
27
27
  - !ruby/object:Gem::Dependency
@@ -62,13 +62,15 @@ files:
62
62
  - lib/libyear_rb.rb
63
63
  - lib/libyear_rb/cli.rb
64
64
  - lib/libyear_rb/dependency_analyzer.rb
65
- - lib/libyear_rb/gem_info_cacher.rb
65
+ - lib/libyear_rb/file_cache.rb
66
+ - lib/libyear_rb/formatters/formatter.rb
67
+ - lib/libyear_rb/formatters/json_formatter.rb
68
+ - lib/libyear_rb/formatters/plaintext_formatter.rb
66
69
  - lib/libyear_rb/gem_info_fetcher.rb
67
70
  - lib/libyear_rb/lockfile_parser.rb
68
71
  - lib/libyear_rb/models.rb
69
- - lib/libyear_rb/reporters/plaintext_reporter.rb
70
- - lib/libyear_rb/reporters/reporter.rb
71
72
  - lib/libyear_rb/runner.rb
73
+ - lib/libyear_rb/sorter.rb
72
74
  - lib/libyear_rb/version.rb
73
75
  homepage: https://github.com/thomascountz/libyear-rb
74
76
  licenses:
@@ -93,7 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
95
  - !ruby/object:Gem::Version
94
96
  version: '0'
95
97
  requirements: []
96
- rubygems_version: 4.0.3
98
+ rubygems_version: 4.0.16
97
99
  specification_version: 4
98
100
  summary: A simple measure of dependency freshness
99
101
  test_files: []
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "fileutils"
5
-
6
- module LibyearRb
7
- module GemInfoCacher
8
- CACHE_EXPIRATION = 86_400 # 24 hours in seconds
9
-
10
- def with_cache(remote_host, gem_name, &block)
11
- if ENV["SKIP_CACHE"] == "1"
12
- return block.call
13
- end
14
-
15
- cache_file = cache_path(remote_host, gem_name)
16
- if cache_valid?(cache_file)
17
- JSON.parse(File.read(cache_file))
18
- else
19
- block.call.tap do |data|
20
- return [] unless data
21
-
22
- FileUtils.mkdir_p(File.dirname(cache_file))
23
- File.write(cache_file, JSON.dump(data))
24
- File.utime(Time.now, Time.now, cache_file)
25
- end
26
- end
27
- end
28
-
29
- private
30
-
31
- def cache_valid?(cache_file)
32
- return false unless File.exist?(cache_file)
33
-
34
- cache_age = Time.now - File.mtime(cache_file)
35
- cache_age < CACHE_EXPIRATION
36
- end
37
-
38
- def cache_dir
39
- ENV["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache")
40
- end
41
-
42
- def cache_path(remote_host, gem_name)
43
- host_key = remote_host.gsub(/[^a-zA-Z0-9]/, "_")
44
- File.join(cache_dir, "libyear-rb", host_key, "#{gem_name}.json")
45
- end
46
- end
47
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LibyearRb
4
- class Reporter
5
- def initialize(io: $stdout)
6
- @io = io
7
- end
8
-
9
- def generate(dependency_freshness)
10
- raise NotImplementedError, "Subclasses must implement the generate method"
11
- end
12
- end
13
- end