toolbus 0.0.2
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 +7 -0
- data/README.md +15 -0
- data/bin/toolbus +5 -0
- data/lib/toolbus.rb +93 -0
- data/lib/utils.rb +51 -0
- data/lib/views.rb +74 -0
- data/spec/fixture/sample.json +87 -0
- data/spec/spec_helper.rb +62 -0
- data/spec/toolbus_spec.rb +5 -0
- metadata +113 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: e446516a16a126e50c3638221d68fcb12478b05a
|
4
|
+
data.tar.gz: 6984f53761683637177632968e9c380e2a3ab4f6
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6179c6fb159e91bd7df282445e8b23b2d036323af0fc3820e5c99918d101a2599636426c2926ec77cade5336e96a8b76eeda175bc320620e24b91752b3e69633
|
7
|
+
data.tar.gz: 9b1f79ea4cba5ba67d7bbf96f508f0cae3f86e5df75d6834aa73ebbeb13c5c1102c88367d9dd017086c5ad01910ff3e95915716606898ae7f7649ee63605a19b
|
data/README.md
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
Blueprint details
|
2
|
+
|
3
|
+
# One reserved word:
|
4
|
+
# ANYTHING.
|
5
|
+
|
6
|
+
# Maybe others are necessary?
|
7
|
+
# ANYTHING_MATCHING("?"). # will cast symbols and strings
|
8
|
+
# CONDITION. a predicate method. will be tested against all children, considered true if it passes.
|
9
|
+
|
10
|
+
Todo:
|
11
|
+
|
12
|
+
* Implement SyntaxTree.include?
|
13
|
+
* Fix StatusBoxView bug - the terminal line isn't clearing properly.
|
14
|
+
* Fill out README.
|
15
|
+
* Test various SyntaxTree inclusions.
|
data/bin/toolbus
ADDED
data/lib/toolbus.rb
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
require 'json/pure'
|
2
|
+
require 'open-uri'
|
3
|
+
require_relative './views.rb'
|
4
|
+
require_relative './utils.rb'
|
5
|
+
|
6
|
+
TOOLBUS_ROOT = File.join(`gem which toolbus`.chomp.chomp("/lib/toolbus.rb"))
|
7
|
+
|
8
|
+
class Toolbus
|
9
|
+
include GitUtils
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
validate_repo
|
13
|
+
@features = fetch_features
|
14
|
+
end
|
15
|
+
|
16
|
+
def validate_repo
|
17
|
+
ConsoleManager.error "Unpushed commits! Push or stash before running." unless latest_commit_online?
|
18
|
+
ConsoleManager.error "Uncommitted changes! Stash or commit and push before running." unless git_status_clean?
|
19
|
+
end
|
20
|
+
|
21
|
+
def fetch_features
|
22
|
+
# TODO: GET all features for our tools and versions, once that API exists
|
23
|
+
JSON.parse(File.read(File.open(File.join(TOOLBUS_ROOT, 'spec/fixture/sample.json'))))['data']
|
24
|
+
end
|
25
|
+
|
26
|
+
SCAN_TIME_SECONDS = 4.0
|
27
|
+
def scan
|
28
|
+
statuses = []
|
29
|
+
progress = 0.0
|
30
|
+
@features.map { |feature| feature.default = 0 } # helps measure progress
|
31
|
+
num_steps = scanning_plan.inject(0) { |total, (file, blueprints)| total + blueprints.length }
|
32
|
+
|
33
|
+
scanning_plan.each_with_index do |(file, search_for), file_index|
|
34
|
+
statuses << "Scanning #{file}"
|
35
|
+
search_for.each do |search_for|
|
36
|
+
id = search_for.keys.first
|
37
|
+
blueprint = search_for.values.first
|
38
|
+
|
39
|
+
progress += 1
|
40
|
+
begin
|
41
|
+
match = SyntaxTree.new(file).include?(SyntaxTree.new(blueprint))
|
42
|
+
rescue Parser::SyntaxError
|
43
|
+
statuses << "Syntax Error: #{file}"
|
44
|
+
next
|
45
|
+
end
|
46
|
+
|
47
|
+
if match
|
48
|
+
feature = @features.find { |feature| feature['id'] == id }
|
49
|
+
feature['count'] += 1
|
50
|
+
statuses << "POST /completions: repo_url: #{repo_url}, feature_id: #{id}, commit: ???, filename: #{file}, first_line: #{match[:first_line]}, last_line: #{match[:last_line]}"
|
51
|
+
end
|
52
|
+
|
53
|
+
percent_complete = (progress / num_steps) * 100
|
54
|
+
ConsoleManager.repaint([
|
55
|
+
ProgressBarView.new(percent_complete),
|
56
|
+
TableView.new(features_found),
|
57
|
+
StatusBoxView.new(statuses),
|
58
|
+
"Found #{num_completions} total completions across #{num_features_completed}/#{@features.count} features across #{file_index}/#{scanning_plan.count} files!"
|
59
|
+
])
|
60
|
+
sleep SCAN_TIME_SECONDS / num_steps
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def scanning_plan
|
68
|
+
hash_with_array_values = Hash.new { |h, k| h[k] = [] }
|
69
|
+
|
70
|
+
@features.inject(hash_with_array_values) do |plan, feature|
|
71
|
+
Dir.glob(feature['search_in']).each do |file|
|
72
|
+
plan[file] << { feature['id'] => feature['search_for'] }
|
73
|
+
end
|
74
|
+
plan
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def feature_module_and_name(feature)
|
79
|
+
[feature['module'], ': ', feature['name']].join
|
80
|
+
end
|
81
|
+
|
82
|
+
def features_found
|
83
|
+
@features.map { |feature| [feature_module_and_name(feature), feature['count']] }.to_h
|
84
|
+
end
|
85
|
+
|
86
|
+
def num_completions
|
87
|
+
@features.inject(0) { |total, feature| total + feature['count'] }
|
88
|
+
end
|
89
|
+
|
90
|
+
def num_features_completed
|
91
|
+
@features.select { |feature| feature['count'] > 0 }.count
|
92
|
+
end
|
93
|
+
end
|
data/lib/utils.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'parser/current'
|
2
|
+
|
3
|
+
class SyntaxTree
|
4
|
+
def initialize(ruby)
|
5
|
+
# todo: turn on
|
6
|
+
# @ast = Parser::CurrentRuby.parse(ruby)
|
7
|
+
end
|
8
|
+
|
9
|
+
def include?(smaller_ast)
|
10
|
+
# TODO: find if smaller_ast is a subset of self.
|
11
|
+
rand < 0.4 ? { first_line: 10, last_line: 12 } : nil
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
module GitUtils
|
16
|
+
attr_reader :repo_url, :head_sha
|
17
|
+
|
18
|
+
def git_status_clean?
|
19
|
+
`git status -s`.length == 0
|
20
|
+
end
|
21
|
+
|
22
|
+
def latest_commit_online?
|
23
|
+
`git log --oneline origin/master..HEAD`.length == 0
|
24
|
+
end
|
25
|
+
|
26
|
+
def repo_url
|
27
|
+
@repo_url ||= `git config --get remote.origin.url`.gsub('git@github.com:', '').chomp
|
28
|
+
end
|
29
|
+
|
30
|
+
def head_sha
|
31
|
+
@head_sha ||= `git rev-parse HEAD`
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Pulled from ActionSupport.
|
36
|
+
class String
|
37
|
+
def truncate(truncate_at, options = {})
|
38
|
+
return dup unless length > truncate_at
|
39
|
+
|
40
|
+
omission = options[:omission] || '...'
|
41
|
+
length_with_room_for_omission = truncate_at - omission.length
|
42
|
+
stop = \
|
43
|
+
if options[:separator]
|
44
|
+
rindex(options[:separator], length_with_room_for_omission) || length_with_room_for_omission
|
45
|
+
else
|
46
|
+
length_with_room_for_omission
|
47
|
+
end
|
48
|
+
|
49
|
+
"#{self[0, stop]}#{omission}"
|
50
|
+
end
|
51
|
+
end
|
data/lib/views.rb
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
module TerminalUtils
|
2
|
+
SCREEN_WIDTH = [`tput cols`.to_i, 100].min
|
3
|
+
SAVE_CURSOR = `tput sc`
|
4
|
+
RESTORE_CURSOR = `tput rc`
|
5
|
+
ERASE_DISPLAY = `tput clear`
|
6
|
+
RED = `tput setaf 1`
|
7
|
+
GREEN = `tput setaf 2`
|
8
|
+
RESET = `tput sgr0`
|
9
|
+
end
|
10
|
+
|
11
|
+
print TerminalUtils::SAVE_CURSOR
|
12
|
+
|
13
|
+
class ConsoleManager
|
14
|
+
include TerminalUtils
|
15
|
+
|
16
|
+
def self.repaint(rows)
|
17
|
+
print RESTORE_CURSOR
|
18
|
+
num_rows = rows.flat_map(&:to_s).length
|
19
|
+
rows.each { |row| puts row.to_s; puts }
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.error(message)
|
23
|
+
puts RED + message + RESET
|
24
|
+
exit
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class ProgressBarView
|
29
|
+
include TerminalUtils
|
30
|
+
EXTRA_CHARS_OFFSET = 8 # brackets, % completion
|
31
|
+
USABLE_WIDTH = SCREEN_WIDTH - EXTRA_CHARS_OFFSET
|
32
|
+
|
33
|
+
def initialize(percent)
|
34
|
+
@percent = Float([percent, 100.0].min)
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_s
|
38
|
+
complete = '#' * ((@percent / 100.0) * USABLE_WIDTH).to_i
|
39
|
+
incomplete = ' ' * (USABLE_WIDTH - complete.length)
|
40
|
+
[GREEN, ' [', complete, incomplete, ']', (@percent.to_i.to_s + '%').rjust(5), RESET].join
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class TableView
|
45
|
+
include TerminalUtils
|
46
|
+
|
47
|
+
def initialize(map)
|
48
|
+
@map = map
|
49
|
+
end
|
50
|
+
|
51
|
+
def to_s
|
52
|
+
@map.map do |(key, count)|
|
53
|
+
color = count > 0 ? GREEN : RED
|
54
|
+
done = count > 0 ? "✓" : " "
|
55
|
+
description = key.truncate(SCREEN_WIDTH - 4).ljust(SCREEN_WIDTH - 3)
|
56
|
+
[color, done, ' ', description, count.to_s, RESET].join
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class StatusBoxView
|
62
|
+
include TerminalUtils
|
63
|
+
|
64
|
+
def initialize(statuses)
|
65
|
+
@statuses = statuses
|
66
|
+
end
|
67
|
+
|
68
|
+
def to_s
|
69
|
+
puts '-' * SCREEN_WIDTH
|
70
|
+
puts
|
71
|
+
num_lines = @statuses.length
|
72
|
+
@statuses.last(8).fill(num_lines, 8 - num_lines) { '' }
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
{
|
2
|
+
"data": [{
|
3
|
+
"type": "feature",
|
4
|
+
"id": 1,
|
5
|
+
"name": "Optimistic locking",
|
6
|
+
"module": "ActiveRecord::Locking",
|
7
|
+
"search_in": "app/models/**/*rb",
|
8
|
+
"search_for": "locking_column = :ANYTHING"
|
9
|
+
}, {
|
10
|
+
"type": "feature",
|
11
|
+
"id": 2,
|
12
|
+
"name": "Pessimistic locking",
|
13
|
+
"module": "ActiveRecord::Locking",
|
14
|
+
"search_in": "app/**/*.rb",
|
15
|
+
"search_for": "lock! OR with_lock"
|
16
|
+
}, {
|
17
|
+
"type": "feature",
|
18
|
+
"id": 3,
|
19
|
+
"name": "Transactions",
|
20
|
+
"module": "ActiveRecord::Transactions",
|
21
|
+
"search_in": "app/**/*.rb",
|
22
|
+
"search_for": "class ANYTHING < ActiveRecord::Base\nend"
|
23
|
+
}, {
|
24
|
+
"type": "feature",
|
25
|
+
"id": 4,
|
26
|
+
"name": "Single table inheritance",
|
27
|
+
"module": "ActiveRecord::Transactions",
|
28
|
+
"search_in": "app/models/**/*rb",
|
29
|
+
"search_for": "ANYTHING < ActiveRecord::Base\nend"
|
30
|
+
}, {
|
31
|
+
"type": "feature",
|
32
|
+
"id": 5,
|
33
|
+
"name": "Alternative ActiveRecord initialization patterns",
|
34
|
+
"module": "ActiveRecord::Base",
|
35
|
+
"search_in": "app/**/*.rb",
|
36
|
+
"search_for": "ANYTHING.new {}"
|
37
|
+
}, {
|
38
|
+
"type": "feature",
|
39
|
+
"id": 6,
|
40
|
+
"name": "Conditions",
|
41
|
+
"module": "ActiveRecord::Base",
|
42
|
+
"search_in": "in AP,P",
|
43
|
+
"search_for": "where(ANYTHING_MATCHING('?'))"
|
44
|
+
}, {
|
45
|
+
"type": "feature",
|
46
|
+
"id": 7,
|
47
|
+
"name": "Saving arrays, hashes, and other non-mappable objects in text columns",
|
48
|
+
"module": "ActiveRecord::AttributeMethods::Serialization",
|
49
|
+
"search_in": "app/models/**/*rb",
|
50
|
+
"search_for": "serialize :ANYTHING"
|
51
|
+
}, {
|
52
|
+
"type": "feature",
|
53
|
+
"id": 8,
|
54
|
+
"name": "Connection to multiple databases in different models",
|
55
|
+
"module": "ActiveRecord::Base",
|
56
|
+
"search_in": "app/**/*.rb",
|
57
|
+
"search_for": "MyModel.connection OR ActiveRecord::Base.connection"
|
58
|
+
}, {
|
59
|
+
"type": "feature",
|
60
|
+
"id": 9,
|
61
|
+
"name": "Connection to MongoDB",
|
62
|
+
"module": "ActiveJob::Enqueuing",
|
63
|
+
"search_in": "app/models/**/*rb",
|
64
|
+
"search_for": "include Mongoid::Document"
|
65
|
+
}, {
|
66
|
+
"type": "feature",
|
67
|
+
"id": 10,
|
68
|
+
"name": "ActiveJob::Enqueueing#enqueue",
|
69
|
+
"module": "ActiveJob::Enqueuing",
|
70
|
+
"search_in": "app/**/*.rb",
|
71
|
+
"search_for": "enqueue"
|
72
|
+
}, {
|
73
|
+
"type": "feature",
|
74
|
+
"id": 11,
|
75
|
+
"name": "ActiveJob::Enqueueing#retry_job",
|
76
|
+
"module": "ActiveSupport::CoreExt",
|
77
|
+
"search_in": "app/jobs/*.rb",
|
78
|
+
"search_for": "retry_job"
|
79
|
+
}, {
|
80
|
+
"type": "feature",
|
81
|
+
"id": 12,
|
82
|
+
"name": "Benchmarking",
|
83
|
+
"module": "ActiveRecord::Inheritance",
|
84
|
+
"search_in": "app/**/*.rb",
|
85
|
+
"search_for": "Benchmark.ms"
|
86
|
+
}]
|
87
|
+
}
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
Bundler.setup
|
3
|
+
|
4
|
+
require 'toolbus'
|
5
|
+
|
6
|
+
RSpec.configure do |config|
|
7
|
+
|
8
|
+
# These options will default to `true` in RSpec 4
|
9
|
+
config.expect_with :rspec do |expectations|
|
10
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
11
|
+
end
|
12
|
+
|
13
|
+
config.mock_with :rspec do |mocks|
|
14
|
+
mocks.verify_partial_doubles = true
|
15
|
+
end
|
16
|
+
|
17
|
+
# These two settings work together to allow you to limit a spec run
|
18
|
+
# to individual examples or groups you care about by tagging them with
|
19
|
+
# `:focus` metadata. When nothing is tagged with `:focus`, all examples
|
20
|
+
# get run.
|
21
|
+
# config.filter_run :focus
|
22
|
+
# config.run_all_when_everything_filtered = true
|
23
|
+
|
24
|
+
# Limits the available syntax to the non-monkey patched syntax that is
|
25
|
+
# recommended. For more details, see:
|
26
|
+
# - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax
|
27
|
+
# - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
|
28
|
+
# - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching
|
29
|
+
config.disable_monkey_patching!
|
30
|
+
|
31
|
+
# This setting enables warnings. It's recommended, but in some cases may
|
32
|
+
# be too noisy due to issues in dependencies.
|
33
|
+
config.warnings = true
|
34
|
+
|
35
|
+
# Many RSpec users commonly either run the entire suite or an individual
|
36
|
+
# file, and it's useful to allow more verbose output when running an
|
37
|
+
# individual spec file.
|
38
|
+
if config.files_to_run.one?
|
39
|
+
# Use the documentation formatter for detailed output,
|
40
|
+
# unless a formatter has already been configured
|
41
|
+
# (e.g. via a command-line flag).
|
42
|
+
config.default_formatter = 'doc'
|
43
|
+
end
|
44
|
+
|
45
|
+
# Print the 10 slowest examples and example groups at the
|
46
|
+
# end of the spec run, to help surface which specs are running
|
47
|
+
# particularly slow.
|
48
|
+
# config.profile_examples = 10
|
49
|
+
|
50
|
+
# Run specs in random order to surface order dependencies. If you find an
|
51
|
+
# order dependency and want to debug it, you can fix the order by providing
|
52
|
+
# the seed, which is printed after each run.
|
53
|
+
# --seed 1234
|
54
|
+
# config.order = :random
|
55
|
+
|
56
|
+
# Seed global randomization in this process using the `--seed` CLI option.
|
57
|
+
# Setting this allows you to use `--seed` to deterministically reproduce
|
58
|
+
# test failures related to randomization by passing the same `--seed` value
|
59
|
+
# as the one that triggered the failure.
|
60
|
+
# Kernel.srand config.seed
|
61
|
+
|
62
|
+
end
|
metadata
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: toolbus
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jason Benn
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-06-05 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: parser
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '2.2'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '2.2'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: json_pure
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.8'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.8'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.2'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: pry
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.10'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0.10'
|
69
|
+
description: Scans and parses clean git repos, figures out how much of an API you've
|
70
|
+
used
|
71
|
+
email: jasoncbenn@gmail.com
|
72
|
+
executables:
|
73
|
+
- toolbus
|
74
|
+
extensions: []
|
75
|
+
extra_rdoc_files: []
|
76
|
+
files:
|
77
|
+
- README.md
|
78
|
+
- bin/toolbus
|
79
|
+
- lib/toolbus.rb
|
80
|
+
- lib/utils.rb
|
81
|
+
- lib/views.rb
|
82
|
+
- spec/fixture/sample.json
|
83
|
+
- spec/spec_helper.rb
|
84
|
+
- spec/toolbus_spec.rb
|
85
|
+
homepage: http://www.jbenn.net
|
86
|
+
licenses:
|
87
|
+
- MIT
|
88
|
+
metadata: {}
|
89
|
+
post_install_message:
|
90
|
+
rdoc_options: []
|
91
|
+
require_paths:
|
92
|
+
- lib
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '0'
|
103
|
+
requirements: []
|
104
|
+
rubyforge_project:
|
105
|
+
rubygems_version: 2.4.5
|
106
|
+
signing_key:
|
107
|
+
specification_version: 4
|
108
|
+
summary: 'To learn a tool: build projects, measure your progress with Toolbus'
|
109
|
+
test_files:
|
110
|
+
- spec/fixture/sample.json
|
111
|
+
- spec/spec_helper.rb
|
112
|
+
- spec/toolbus_spec.rb
|
113
|
+
has_rdoc:
|