Skip to content

Commit

Permalink
Add auto-correct support
Browse files Browse the repository at this point in the history
Add support for `-a` or `--auto-correct` flag: automatically update
Gemfile(s) to use compliant version definition where possible.
Re-evaluate Gemfile afterwards.
  • Loading branch information
bobf committed Jan 25, 2020
1 parent 9a321c9 commit 9992e0b
Show file tree
Hide file tree
Showing 20 changed files with 259 additions and 52 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The benefit of applying this standard is that, if all gems follow [Semantic Vers
Add the gem to your `Gemfile`

```ruby
gem 'strong_versions', '~> 0.3.2'
gem 'strong_versions', '~> 0.4.0'
```

And rebuild your bundle:
Expand All @@ -39,7 +39,7 @@ $ bundle install

Or install yourself:
```bash
$ gem install strong_versions -v '0.3.2'
$ gem install strong_versions -v '0.4.0'
```

## Usage
Expand All @@ -54,6 +54,11 @@ The executable will output all non-passing gems and will return an exit code of

![StrongVersions](doc/images/ci-pipeline.png)

If you are feeling brave, auto-correct is available:
```bash
$ bundle exec strong_versions -a
```

### Exclusions

<a name="ignore"></a>You can tell _StrongVersions_ to ignore any of your gems (e.g. those that don't follow _semantic versioning_) by adding them to the `ignore` section of `.strong_versions.yml` in your project root, e.g.:
Expand Down
25 changes: 21 additions & 4 deletions bin/strong_versions
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
#!/usr/bin/env ruby

require 'optparse'

require 'strong_versions'

options = {}
OptionParser.new do |opts|
opts.banner = "Usage: strong_versions [options]"

opts.on("-a", "--auto-correct", "Auto-correct (use with caution)") do |v|
options[:auto_correct] = true
end
end.parse!

def dependencies
StrongVersions::DependencyFinder.new.dependencies
end

config_path = Bundler.root.join('.strong_versions.yml')
config = StrongVersions::Config.new(config_path)
dependencies = StrongVersions::DependencyFinder.new.dependencies
valid = StrongVersions::Dependencies.new(dependencies).validate!(
validated = StrongVersions::Dependencies.new(dependencies).validate!(
except: config.exceptions,
on_failure: 'warn'
on_failure: 'warn',
auto_correct: options[:auto_correct]
)

exit 0 if valid
exec "#{$0}" if options[:auto_correct]

exit 0 if validated
exit 1
35 changes: 35 additions & 0 deletions bin/strong_versions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
require 'optparse'

require 'strong_versions'

options = {}
OptionParser.new do |opts|
opts.banner = "Usage: strong_versions [options]"

opts.on("-a", "--auto-correct", "Auto-correct (use with caution)") do |v|
options[:auto_correct] = true
end
end.parse!

def dependencies
StrongVersions::DependencyFinder.new.dependencies
end

config_path = Bundler.root.join('.strong_versions.yml')
config = StrongVersions::Config.new(config_path)
validated = StrongVersions::Dependencies.new(dependencies).validate!(
except: config.exceptions,
on_failure: 'warn',
auto_correct: options[:auto_correct]
)

revalidated = false
revalidated = StrongVersions::Dependencies.new(dependencies).validate!(
except: config.exceptions,
on_failure: 'warn',
auto_correct: false
) if options[:auto_correct]

exit 0 if validated or revalidated
exit 1

Binary file modified doc/images/strong-versions-example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions lib/strong_versions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require 'strong_versions/dependency'
require 'strong_versions/dependency_finder'
require 'strong_versions/dependencies'
require 'strong_versions/errors'
require 'strong_versions/suggestion'
require 'strong_versions/terminal'
require 'strong_versions/version'
Expand Down
44 changes: 43 additions & 1 deletion lib/strong_versions/dependencies.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,23 @@ def initialize(dependencies)
end

def validate!(options = {})
auto_correct = options.delete(:auto_correct) { false }
if validate(options)
summary
return true
end

return update_gemfile if auto_correct

raise_or_warn(options.fetch(:on_failure, 'raise'))
summary
false
end

def validate(options = {})
unsafe_autocorrect_error if options[:auto_correct]
@dependencies.each do |dependency|
next if options.fetch(:except).include?(dependency.name)
next if options.fetch(:except, []).include?(dependency.name)
next if dependency.valid?

@invalid_gems.push(dependency) unless dependency.valid?
Expand All @@ -33,10 +37,48 @@ def validate(options = {})

private

def unsafe_autocorrect_error
raise UnsafeAutoCorrectError, 'Must use #validate! for autocorrect'
end

def summary
@terminal.summary(@dependencies.size, @invalid_gems.size)
end

def update_gemfile
updated = 0
@dependencies.each do |dependency|
next unless dependency.updatable?

updated += 1 if update_dependency(dependency)
end
@terminal.update_summary(updated)
end

def update_dependency(dependency)
path = dependency.gemfile
content = File.read(path)
update = replace_gem_definition(dependency, content)
return false if content == update

File.write(path, update)
@terminal.gem_update(path, dependency)
true
end

def replace_gem_definition(dependency, content)
regex = gem_regex(dependency.name)
match = content.match(regex)
return content unless match

indent = match.captures.first
content.gsub(regex, "#{indent}#{dependency.suggested_definition}")
end

def gem_regex(name)
/^(\s*)gem\s+['"]#{name}['"].*$/
end

def raise_or_warn(on_failure)
case on_failure
when 'raise'
Expand Down
43 changes: 40 additions & 3 deletions lib/strong_versions/dependency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ def initialize(dependency, lockfile = nil)
end
end

def gemfile
Pathname.new(@dependency.gemfile) if @dependency.respond_to?(:gemfile)
end

def valid?
@errors.empty?
end
Expand All @@ -23,10 +27,24 @@ def suggestion
Suggestion.new(lockfile_version)
end

def suggested_definition
guards = guard_versions.map { |op, version| "'#{op} #{version}'" }
combined = [suggestion, *guards].join(', ')
"gem '#{@name}', #{combined}"
end

def definition
versions.map { |operator, version| "'#{operator} #{version}'" }.join(', ')
end

def updatable?
return false unless gemfile
return false if suggestion.missing?
return false if path_source?

true
end

private

def versions
Expand All @@ -35,6 +53,11 @@ def versions
end
end

def guard_versions
versions.reject { |op, _version| pessimistic?(op) }
.reject { |op, version| redundant?(op, version) }
end

def parse_version(requirement)
operator, version_obj = Gem::Requirement.parse(requirement)
[operator, version(version_obj)]
Expand Down Expand Up @@ -84,6 +107,21 @@ def check_valid_version(version)
@errors << { type: :version, value: value }
end

def redundant?(operator, version)
return false unless operator.start_with?('>')

multiply_version(version) <= multiply_version(suggestion.version)
end

def multiply_version(version)
components = version.split('.')
# Support extremely precise versions e.g. '1.2.3.4.5.6.7.8.9'
components += ['0'] * (10 - components.size)
components.reverse.each_with_index.map do |component, index|
component.to_i * 10.pow(index + 1)
end.sum
end

def pessimistic?(operator)
operator == '~>'
end
Expand Down Expand Up @@ -112,9 +150,8 @@ def pessimistic_with_upper_bound?(operator)
end

def any_pessimistic?
versions.any? do |_version, operator|
%w[< <= ~>].include?(operator)
end
p versions
versions.any? { |operator, _version| pessimistic?(operator) }
end
end
end
1 change: 1 addition & 0 deletions lib/strong_versions/dependency_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def dependencies

def development
# Gem runtime dependencies are not included here:
Bundler.definition.resolve
Bundler.definition.dependencies
end

Expand Down
6 changes: 6 additions & 0 deletions lib/strong_versions/errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

module StrongVersions
class Error < StandardError; end
class UnsafeAutoCorrectError < Error; end
end
30 changes: 17 additions & 13 deletions lib/strong_versions/suggestion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
module StrongVersions
class Suggestion
def initialize(version)
@parts = version.split('.') unless version.nil?
return if version.nil?

@parts = version.split('.')
# Treat '4.3.2.1' as '4.3.2'
@parts.pop if standard?(@parts.first(3)) && @parts.size == 4
end

def to_s
Expand All @@ -12,15 +16,6 @@ def to_s
"'~> #{version}'"
end

def missing?
return false if stable?
return false if unstable?

true
end

private

def version
major, minor, patch = @parts if standard?

Expand All @@ -30,6 +25,15 @@ def version
nil
end

def missing?
return false if stable?
return false if unstable?

true
end

private

def unstable?
standard? && @parts.first.to_i.zero?
end
Expand All @@ -38,9 +42,9 @@ def stable?
standard? && @parts.first.to_i >= 1
end

def standard?
return false if @parts.nil?
return false unless @parts.size == 3
def standard?(parts = @parts)
return false if parts.nil?
return false unless parts.size == 3
return false unless numeric?

true
Expand Down
20 changes: 20 additions & 0 deletions lib/strong_versions/terminal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ def warn(string)
puts(color(string, :underline, :bright, :red))
end

def gem_update(path, gem)
relpath = path.relative_path_from(Pathname.new(Dir.pwd))
output = [
color("[#{relpath}] ", :cyan),
color(gem.suggested_definition, :green),
color(' (was: ', :default),
color(gem.definition, :red),
color(')', :default)
].join
puts(output)
end

def update_summary(updated)
output = [
color(updated.to_s, :green),
color(' gem definitions updated.')
].join
puts("\n#{output}")
end

def summary(count, failed)
return puts(success(count)) if failed.zero?

Expand Down
2 changes: 1 addition & 1 deletion lib/strong_versions/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module StrongVersions
VERSION = '0.3.2'
VERSION = '0.4.0'
end
5 changes: 5 additions & 0 deletions spec/fixtures/Gemfile.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
source 'https://rubygems.org'

ruby '2.5.3'

gem 'test_gem', '~> 1'
12 changes: 0 additions & 12 deletions spec/fixtures/Gemfile.installed

This file was deleted.

Loading

0 comments on commit 9992e0b

Please sign in to comment.