ruby-maat 1.0.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 +7 -0
- data/.commitlintrc.json +44 -0
- data/.mailmap +3 -0
- data/.overcommit.yml +77 -0
- data/.release-please-config.json +33 -0
- data/.release-please-manifest.json +3 -0
- data/.rspec +3 -0
- data/.rubocop.yml +48 -0
- data/CHANGELOG.md +46 -0
- data/CI_CD_SETUP.md +180 -0
- data/CLAUDE.md +130 -0
- data/Dockerfile +40 -0
- data/README.md +444 -0
- data/README_RUBY.md +300 -0
- data/RELEASE_PLEASE_SETUP.md +198 -0
- data/RUBY_MAAT.md +227 -0
- data/Rakefile +12 -0
- data/doc/imgs/abs_churn_sample.png +0 -0
- data/doc/imgs/code_age_sample.png +0 -0
- data/doc/imgs/coupling_sample.png +0 -0
- data/doc/imgs/crime_cover.jpg +0 -0
- data/doc/imgs/tree_map_sample.png +0 -0
- data/doc/intro.md +3 -0
- data/exe/ruby-maat +6 -0
- data/lib/ruby_maat/analysis/authors.rb +47 -0
- data/lib/ruby_maat/analysis/base_analysis.rb +70 -0
- data/lib/ruby_maat/analysis/churn.rb +255 -0
- data/lib/ruby_maat/analysis/code_age.rb +53 -0
- data/lib/ruby_maat/analysis/commit_messages.rb +58 -0
- data/lib/ruby_maat/analysis/communication.rb +56 -0
- data/lib/ruby_maat/analysis/effort.rb +150 -0
- data/lib/ruby_maat/analysis/entities.rb +40 -0
- data/lib/ruby_maat/analysis/identity.rb +12 -0
- data/lib/ruby_maat/analysis/logical_coupling.rb +134 -0
- data/lib/ruby_maat/analysis/sum_of_coupling.rb +43 -0
- data/lib/ruby_maat/analysis/summary.rb +43 -0
- data/lib/ruby_maat/app.rb +143 -0
- data/lib/ruby_maat/change_record.rb +47 -0
- data/lib/ruby_maat/cli.rb +187 -0
- data/lib/ruby_maat/dataset.rb +205 -0
- data/lib/ruby_maat/groupers/layer_grouper.rb +67 -0
- data/lib/ruby_maat/groupers/team_mapper.rb +51 -0
- data/lib/ruby_maat/groupers/time_grouper.rb +70 -0
- data/lib/ruby_maat/output/csv_output.rb +65 -0
- data/lib/ruby_maat/parsers/base_parser.rb +63 -0
- data/lib/ruby_maat/parsers/git2_parser.rb +72 -0
- data/lib/ruby_maat/parsers/git_parser.rb +66 -0
- data/lib/ruby_maat/parsers/mercurial_parser.rb +64 -0
- data/lib/ruby_maat/parsers/perforce_parser.rb +77 -0
- data/lib/ruby_maat/parsers/svn_parser.rb +76 -0
- data/lib/ruby_maat/parsers/tfs_parser.rb +103 -0
- data/lib/ruby_maat/version.rb +5 -0
- data/lib/ruby_maat.rb +44 -0
- metadata +143 -0
@@ -0,0 +1,150 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMaat
|
4
|
+
module Analysis
|
5
|
+
module Effort
|
6
|
+
# Entity effort analysis - revisions per author per entity
|
7
|
+
class ByRevisions < BaseAnalysis
|
8
|
+
def analyze(dataset, _options = {})
|
9
|
+
# Group by entity and author, count revisions
|
10
|
+
results = {}
|
11
|
+
|
12
|
+
dataset.to_df.each_row do |row|
|
13
|
+
entity = row["entity"]
|
14
|
+
author = row["author"]
|
15
|
+
revision = row["revision"]
|
16
|
+
|
17
|
+
key = [entity, author]
|
18
|
+
results[key] ||= {
|
19
|
+
entity: entity,
|
20
|
+
author: author,
|
21
|
+
author_revs: Set.new,
|
22
|
+
total_revs: Set.new
|
23
|
+
}
|
24
|
+
results[key][:author_revs] << revision
|
25
|
+
end
|
26
|
+
|
27
|
+
# Calculate total revisions per entity
|
28
|
+
entity_totals = {}
|
29
|
+
dataset.to_df.each_row do |row|
|
30
|
+
entity = row["entity"]
|
31
|
+
revision = row["revision"]
|
32
|
+
|
33
|
+
entity_totals[entity] ||= Set.new
|
34
|
+
entity_totals[entity] << revision
|
35
|
+
end
|
36
|
+
|
37
|
+
# Format results
|
38
|
+
formatted_results = results.map do |(entity, author), data|
|
39
|
+
{
|
40
|
+
entity: entity,
|
41
|
+
author: author,
|
42
|
+
"author-revs": data[:author_revs].size,
|
43
|
+
"total-revs": entity_totals[entity].size
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
# Sort by entity, then by author revisions descending
|
48
|
+
formatted_results.sort! do |a, b|
|
49
|
+
entity_comparison = a[:entity] <=> b[:entity]
|
50
|
+
entity_comparison.zero? ? b[:"author-revs"] <=> a[:"author-revs"] : entity_comparison
|
51
|
+
end
|
52
|
+
|
53
|
+
to_csv_data(formatted_results, %i[entity author author-revs total-revs])
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Main developer by revisions - primary contributor per entity (by commit count)
|
58
|
+
class MainDeveloperByRevisions < BaseAnalysis
|
59
|
+
def analyze(dataset, options = {})
|
60
|
+
min_revs = options[:min_revs] || 5
|
61
|
+
|
62
|
+
# Group by entity and author, count revisions
|
63
|
+
entity_authors = {}
|
64
|
+
|
65
|
+
dataset.to_df.each_row do |row|
|
66
|
+
entity = row["entity"]
|
67
|
+
author = row["author"]
|
68
|
+
revision = row["revision"]
|
69
|
+
|
70
|
+
entity_authors[entity] ||= {}
|
71
|
+
entity_authors[entity][author] ||= Set.new
|
72
|
+
entity_authors[entity][author] << revision
|
73
|
+
end
|
74
|
+
|
75
|
+
# Find main developer for each entity
|
76
|
+
results = []
|
77
|
+
|
78
|
+
entity_authors.each do |entity, authors|
|
79
|
+
total_revisions = authors.values.map(&:size).sum
|
80
|
+
next if total_revisions < min_revs
|
81
|
+
|
82
|
+
# Find author with most revisions
|
83
|
+
main_author, revisions = authors.max_by { |_author, revs| revs.size }
|
84
|
+
|
85
|
+
total_revisions = authors.values.map(&:size).sum
|
86
|
+
|
87
|
+
results << {
|
88
|
+
entity: entity,
|
89
|
+
"main-dev": main_author,
|
90
|
+
added: revisions.size, # Number of revisions by main dev
|
91
|
+
"total-added": total_revisions,
|
92
|
+
ownership: total_revisions.positive? ? (revisions.size.to_f / total_revisions).round(2) : 0.0
|
93
|
+
}
|
94
|
+
end
|
95
|
+
|
96
|
+
# Sort by number of revisions descending
|
97
|
+
results.sort_by! { |r| -r[:added] }
|
98
|
+
|
99
|
+
to_csv_data(results, %i[entity main-dev added total-added ownership])
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Fragmentation analysis - measures ownership distribution (fractal value)
|
104
|
+
class Fragmentation < BaseAnalysis
|
105
|
+
def analyze(dataset, options = {})
|
106
|
+
min_revs = options[:min_revs] || 5
|
107
|
+
|
108
|
+
# Group by entity, count contributions per author
|
109
|
+
entity_contributions = {}
|
110
|
+
|
111
|
+
dataset.to_df.each_row do |row|
|
112
|
+
entity = row["entity"]
|
113
|
+
author = row["author"]
|
114
|
+
revision = row["revision"]
|
115
|
+
|
116
|
+
entity_contributions[entity] ||= {}
|
117
|
+
entity_contributions[entity][author] ||= Set.new
|
118
|
+
entity_contributions[entity][author] << revision
|
119
|
+
end
|
120
|
+
|
121
|
+
# Calculate fragmentation (fractal value) for each entity
|
122
|
+
results = []
|
123
|
+
|
124
|
+
entity_contributions.each do |entity, authors|
|
125
|
+
total_revisions = authors.values.map(&:size).sum
|
126
|
+
next if total_revisions < min_revs
|
127
|
+
|
128
|
+
# Calculate fractal value: 1 - sum(p_i^2) where p_i is proportion of each author
|
129
|
+
sum_of_squares = authors.values.map do |revisions|
|
130
|
+
proportion = revisions.size.to_f / total_revisions
|
131
|
+
proportion**2
|
132
|
+
end.sum
|
133
|
+
|
134
|
+
fractal_value = 1.0 - sum_of_squares
|
135
|
+
|
136
|
+
results << {
|
137
|
+
entity: entity,
|
138
|
+
fractal_value: fractal_value.round(3)
|
139
|
+
}
|
140
|
+
end
|
141
|
+
|
142
|
+
# Sort by fractal value descending (most fragmented first)
|
143
|
+
results.sort_by! { |r| -r[:fractal_value] }
|
144
|
+
|
145
|
+
to_csv_data(results, %i[entity fractal_value])
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMaat
|
4
|
+
module Analysis
|
5
|
+
# Entities analysis - counts revisions per entity
|
6
|
+
class Entities < BaseAnalysis
|
7
|
+
def analyze(dataset, options = {})
|
8
|
+
min_revs = options[:min_revs] || 1
|
9
|
+
|
10
|
+
# Group by entity and count revisions manually
|
11
|
+
entity_stats = {}
|
12
|
+
|
13
|
+
dataset.to_df.to_a.each do |row|
|
14
|
+
entity = row["entity"]
|
15
|
+
revision = row["revision"]
|
16
|
+
|
17
|
+
entity_stats[entity] ||= Set.new
|
18
|
+
entity_stats[entity] << revision
|
19
|
+
end
|
20
|
+
|
21
|
+
# Build results and apply minimum revisions filter
|
22
|
+
results = []
|
23
|
+
entity_stats.each do |entity, revisions|
|
24
|
+
n_revs = revisions.size
|
25
|
+
next if n_revs < min_revs
|
26
|
+
|
27
|
+
results << {
|
28
|
+
entity: entity,
|
29
|
+
"n-revs": n_revs
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
# Sort by number of revisions (descending)
|
34
|
+
results.sort! { |a, b| b[:"n-revs"] <=> a[:"n-revs"] }
|
35
|
+
|
36
|
+
to_csv_data(results, [:entity, :"n-revs"])
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMaat
|
4
|
+
module Analysis
|
5
|
+
# Logical coupling analysis - finds modules that tend to change together
|
6
|
+
# This identifies hidden dependencies between code modules
|
7
|
+
class LogicalCoupling < BaseAnalysis
|
8
|
+
def analyze(dataset, options = {})
|
9
|
+
min_revs = options[:min_revs] || 1
|
10
|
+
min_shared_revs = options[:min_shared_revs] || 1
|
11
|
+
min_coupling = options[:min_coupling] || 1
|
12
|
+
max_coupling = options[:max_coupling] || 100
|
13
|
+
max_changeset_size = options[:max_changeset_size] || 30
|
14
|
+
verbose_results = options[:verbose_results] || false
|
15
|
+
|
16
|
+
# Get co-changing entities by revision
|
17
|
+
co_changing_entities = get_co_changing_entities(dataset, max_changeset_size)
|
18
|
+
|
19
|
+
# Calculate coupling frequencies
|
20
|
+
coupling_frequencies = calculate_coupling_frequencies(co_changing_entities)
|
21
|
+
|
22
|
+
# Calculate revision counts per entity
|
23
|
+
entity_revisions = calculate_entity_revisions(dataset)
|
24
|
+
|
25
|
+
# Generate coupling results
|
26
|
+
results = []
|
27
|
+
|
28
|
+
coupling_frequencies.each do |(entity1, entity2), shared_revs|
|
29
|
+
entity1_revs = entity_revisions[entity1] || 0
|
30
|
+
entity2_revs = entity_revisions[entity2] || 0
|
31
|
+
|
32
|
+
avg_revs = average(entity1_revs, entity2_revs)
|
33
|
+
coupling_degree = percentage(shared_revs, avg_revs)
|
34
|
+
|
35
|
+
# Apply thresholds
|
36
|
+
next unless avg_revs >= min_revs
|
37
|
+
next unless shared_revs >= min_shared_revs
|
38
|
+
next unless coupling_degree >= min_coupling
|
39
|
+
next unless coupling_degree <= max_coupling
|
40
|
+
|
41
|
+
result = {
|
42
|
+
entity: entity1,
|
43
|
+
coupled: entity2,
|
44
|
+
degree: coupling_degree,
|
45
|
+
"average-revs": avg_revs.ceil
|
46
|
+
}
|
47
|
+
|
48
|
+
if verbose_results
|
49
|
+
result.merge!(
|
50
|
+
"first-entity-revisions": entity1_revs,
|
51
|
+
"second-entity-revisions": entity2_revs,
|
52
|
+
"shared-revisions": shared_revs
|
53
|
+
)
|
54
|
+
end
|
55
|
+
|
56
|
+
results << result
|
57
|
+
end
|
58
|
+
|
59
|
+
# Sort by coupling degree (descending), then by average revisions (descending)
|
60
|
+
results.sort! do |a, b|
|
61
|
+
comparison = b[:degree] <=> a[:degree]
|
62
|
+
comparison.zero? ? b[:"average-revs"] <=> a[:"average-revs"] : comparison
|
63
|
+
end
|
64
|
+
|
65
|
+
columns = [:entity, :coupled, :degree, :"average-revs"]
|
66
|
+
columns += [:"first-entity-revisions", :"second-entity-revisions", :"shared-revisions"] if verbose_results
|
67
|
+
|
68
|
+
to_csv_data(results, columns)
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def get_co_changing_entities(dataset, max_changeset_size)
|
74
|
+
# Group changes by revision to find entities that changed together
|
75
|
+
by_revision = {}
|
76
|
+
|
77
|
+
dataset.to_df.to_a.each do |row|
|
78
|
+
revision = row["revision"]
|
79
|
+
entity = row["entity"]
|
80
|
+
|
81
|
+
by_revision[revision] ||= []
|
82
|
+
by_revision[revision] << entity
|
83
|
+
end
|
84
|
+
|
85
|
+
# Convert to co-changing pairs, filtering by changeset size
|
86
|
+
co_changing = []
|
87
|
+
|
88
|
+
by_revision.each_value do |entities|
|
89
|
+
# Skip large changesets to avoid noise
|
90
|
+
next if entities.size > max_changeset_size
|
91
|
+
|
92
|
+
# Get unique entities (remove duplicates)
|
93
|
+
unique_entities = entities.uniq
|
94
|
+
|
95
|
+
# Generate all combinations of 2 entities
|
96
|
+
unique_entities.combination(2) do |entity1, entity2|
|
97
|
+
# Sort to ensure consistent ordering
|
98
|
+
pair = [entity1, entity2].sort
|
99
|
+
co_changing << pair
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
co_changing
|
104
|
+
end
|
105
|
+
|
106
|
+
def calculate_coupling_frequencies(co_changing_entities)
|
107
|
+
# Count how many times each pair changed together
|
108
|
+
frequencies = Hash.new(0)
|
109
|
+
|
110
|
+
co_changing_entities.each do |pair|
|
111
|
+
frequencies[pair] += 1
|
112
|
+
end
|
113
|
+
|
114
|
+
frequencies
|
115
|
+
end
|
116
|
+
|
117
|
+
def calculate_entity_revisions(dataset)
|
118
|
+
# Count unique revisions per entity from the dataset
|
119
|
+
entity_revisions = {}
|
120
|
+
|
121
|
+
dataset.to_df.to_a.each do |row|
|
122
|
+
entity = row["entity"]
|
123
|
+
revision = row["revision"]
|
124
|
+
|
125
|
+
entity_revisions[entity] ||= Set.new
|
126
|
+
entity_revisions[entity] << revision
|
127
|
+
end
|
128
|
+
|
129
|
+
# Convert to counts
|
130
|
+
entity_revisions.transform_values(&:size)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMaat
|
4
|
+
module Analysis
|
5
|
+
# Sum of coupling analysis - aggregated coupling metrics per entity
|
6
|
+
class SumOfCoupling < BaseAnalysis
|
7
|
+
def analyze(dataset, options = {})
|
8
|
+
# First run the logical coupling analysis to get coupling data
|
9
|
+
coupling_analysis = LogicalCoupling.new
|
10
|
+
coupling_results = coupling_analysis.analyze(dataset, options)
|
11
|
+
|
12
|
+
# If no coupling results, return empty
|
13
|
+
return to_csv_data([], %i[entity soc]) if coupling_results.empty?
|
14
|
+
|
15
|
+
# Aggregate coupling degrees per entity
|
16
|
+
entity_coupling_sums = Hash.new(0)
|
17
|
+
|
18
|
+
coupling_results.each_row do |row|
|
19
|
+
entity = row["entity"]
|
20
|
+
coupled = row["coupled"]
|
21
|
+
degree = row["degree"]
|
22
|
+
|
23
|
+
# Add coupling for both directions
|
24
|
+
entity_coupling_sums[entity] += degree
|
25
|
+
entity_coupling_sums[coupled] += degree
|
26
|
+
end
|
27
|
+
|
28
|
+
# Calculate sum of coupling for each entity
|
29
|
+
results = entity_coupling_sums.map do |entity, total_coupling|
|
30
|
+
{
|
31
|
+
entity: entity,
|
32
|
+
soc: total_coupling
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
# Sort by sum of coupling descending
|
37
|
+
results.sort_by! { |r| -r[:soc] }
|
38
|
+
|
39
|
+
to_csv_data(results, %i[entity soc])
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMaat
|
4
|
+
module Analysis
|
5
|
+
# Summary analysis - provides high-level overview of repository statistics
|
6
|
+
class Summary < BaseAnalysis
|
7
|
+
def analyze(dataset, _options = {})
|
8
|
+
df = dataset.to_df
|
9
|
+
|
10
|
+
if df.empty?
|
11
|
+
results = [
|
12
|
+
{statistic: "number-of-commits", value: 0},
|
13
|
+
{statistic: "number-of-entities", value: 0},
|
14
|
+
{statistic: "number-of-entities-changed", value: 0},
|
15
|
+
{statistic: "number-of-authors", value: 0}
|
16
|
+
]
|
17
|
+
else
|
18
|
+
# Collect data manually to avoid DataFrame API issues
|
19
|
+
revisions = []
|
20
|
+
entities = []
|
21
|
+
authors = []
|
22
|
+
total_changes = 0
|
23
|
+
|
24
|
+
df.each_row do |row|
|
25
|
+
revisions << row["revision"]
|
26
|
+
entities << row["entity"]
|
27
|
+
authors << row["author"]
|
28
|
+
total_changes += 1
|
29
|
+
end
|
30
|
+
|
31
|
+
results = [
|
32
|
+
{statistic: "number-of-commits", value: revisions.uniq.size},
|
33
|
+
{statistic: "number-of-entities", value: entities.uniq.size},
|
34
|
+
{statistic: "number-of-entities-changed", value: total_changes},
|
35
|
+
{statistic: "number-of-authors", value: authors.uniq.size}
|
36
|
+
]
|
37
|
+
end
|
38
|
+
|
39
|
+
to_csv_data(results, %i[statistic value])
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "date"
|
4
|
+
|
5
|
+
module RubyMaat
|
6
|
+
# Main application orchestration
|
7
|
+
# This is the Ruby equivalent of code-maat.app.app namespace
|
8
|
+
class App
|
9
|
+
SUPPORTED_VCS = %w[git git2 svn hg p4 tfs].freeze
|
10
|
+
|
11
|
+
SUPPORTED_ANALYSES = {
|
12
|
+
"authors" => RubyMaat::Analysis::Authors,
|
13
|
+
"revisions" => RubyMaat::Analysis::Entities,
|
14
|
+
"coupling" => RubyMaat::Analysis::LogicalCoupling,
|
15
|
+
"soc" => RubyMaat::Analysis::SumOfCoupling,
|
16
|
+
"summary" => RubyMaat::Analysis::Summary,
|
17
|
+
"identity" => RubyMaat::Analysis::Identity,
|
18
|
+
"abs-churn" => RubyMaat::Analysis::Churn::Absolute,
|
19
|
+
"author-churn" => RubyMaat::Analysis::Churn::ByAuthor,
|
20
|
+
"entity-churn" => RubyMaat::Analysis::Churn::ByEntity,
|
21
|
+
"entity-ownership" => RubyMaat::Analysis::Churn::Ownership,
|
22
|
+
"main-dev" => RubyMaat::Analysis::Churn::MainDeveloper,
|
23
|
+
"refactoring-main-dev" => RubyMaat::Analysis::Churn::RefactoringMainDeveloper,
|
24
|
+
"entity-effort" => RubyMaat::Analysis::Effort::ByRevisions,
|
25
|
+
"main-dev-by-revs" => RubyMaat::Analysis::Effort::MainDeveloperByRevisions,
|
26
|
+
"fragmentation" => RubyMaat::Analysis::Effort::Fragmentation,
|
27
|
+
"communication" => RubyMaat::Analysis::Communication,
|
28
|
+
"messages" => RubyMaat::Analysis::CommitMessages,
|
29
|
+
"age" => RubyMaat::Analysis::CodeAge
|
30
|
+
}.freeze
|
31
|
+
|
32
|
+
def self.analysis_names
|
33
|
+
SUPPORTED_ANALYSES.keys.sort.join(", ")
|
34
|
+
end
|
35
|
+
|
36
|
+
def initialize(options = {})
|
37
|
+
@options = options
|
38
|
+
validate_options!
|
39
|
+
end
|
40
|
+
|
41
|
+
def run
|
42
|
+
# Parse VCS log file
|
43
|
+
parser = create_parser
|
44
|
+
change_records = parser.parse
|
45
|
+
|
46
|
+
# Apply data transformations
|
47
|
+
change_records = apply_grouping(change_records)
|
48
|
+
change_records = apply_temporal_grouping(change_records)
|
49
|
+
change_records = apply_team_mapping(change_records)
|
50
|
+
|
51
|
+
# Convert to dataset
|
52
|
+
dataset = Dataset.from_changes(change_records)
|
53
|
+
|
54
|
+
# Run analysis
|
55
|
+
analysis = create_analysis
|
56
|
+
results = analysis.analyze(dataset, @options)
|
57
|
+
|
58
|
+
# Output results
|
59
|
+
output_handler = create_output_handler
|
60
|
+
output_handler.write(results)
|
61
|
+
rescue => e
|
62
|
+
handle_error(e)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def validate_options!
|
68
|
+
raise ArgumentError, "Log file is required" unless @options[:log]
|
69
|
+
raise ArgumentError, "Version control system is required" unless @options[:version_control]
|
70
|
+
|
71
|
+
unless SUPPORTED_VCS.include?(@options[:version_control])
|
72
|
+
raise ArgumentError, "Invalid VCS: #{@options[:version_control]}. Supported: #{SUPPORTED_VCS.join(", ")}"
|
73
|
+
end
|
74
|
+
|
75
|
+
return if SUPPORTED_ANALYSES.key?(@options[:analysis] || "authors")
|
76
|
+
|
77
|
+
raise ArgumentError, "Invalid analysis: #{@options[:analysis]}. Supported: #{self.class.analysis_names}"
|
78
|
+
end
|
79
|
+
|
80
|
+
def create_parser
|
81
|
+
case @options[:version_control]
|
82
|
+
when "git"
|
83
|
+
RubyMaat::Parsers::GitParser.new(@options[:log], @options)
|
84
|
+
when "git2"
|
85
|
+
RubyMaat::Parsers::Git2Parser.new(@options[:log], @options)
|
86
|
+
when "svn"
|
87
|
+
RubyMaat::Parsers::SvnParser.new(@options[:log], @options)
|
88
|
+
when "hg"
|
89
|
+
RubyMaat::Parsers::MercurialParser.new(@options[:log], @options)
|
90
|
+
when "p4"
|
91
|
+
RubyMaat::Parsers::PerforceParser.new(@options[:log], @options)
|
92
|
+
when "tfs"
|
93
|
+
RubyMaat::Parsers::TfsParser.new(@options[:log], @options)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def apply_grouping(change_records)
|
98
|
+
return change_records unless @options[:group]
|
99
|
+
|
100
|
+
grouper = RubyMaat::Groupers::LayerGrouper.new(@options[:group])
|
101
|
+
grouper.group(change_records)
|
102
|
+
end
|
103
|
+
|
104
|
+
def apply_temporal_grouping(change_records)
|
105
|
+
return change_records unless @options[:temporal_period]
|
106
|
+
|
107
|
+
grouper = RubyMaat::Groupers::TimeGrouper.new(@options[:temporal_period])
|
108
|
+
grouper.group(change_records)
|
109
|
+
end
|
110
|
+
|
111
|
+
def apply_team_mapping(change_records)
|
112
|
+
return change_records unless @options[:team_map_file]
|
113
|
+
|
114
|
+
mapper = RubyMaat::Groupers::TeamMapper.new(@options[:team_map_file])
|
115
|
+
mapper.map(change_records)
|
116
|
+
end
|
117
|
+
|
118
|
+
def create_analysis
|
119
|
+
analysis_name = @options[:analysis] || "authors"
|
120
|
+
analysis_class = SUPPORTED_ANALYSES[analysis_name]
|
121
|
+
analysis_class.new
|
122
|
+
end
|
123
|
+
|
124
|
+
def create_output_handler
|
125
|
+
if @options[:outfile]
|
126
|
+
RubyMaat::Output::CsvOutput.new(@options[:outfile], @options[:rows])
|
127
|
+
else
|
128
|
+
RubyMaat::Output::CsvOutput.new(nil, @options[:rows]) # stdout
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def handle_error(error)
|
133
|
+
case error
|
134
|
+
when ArgumentError
|
135
|
+
warn "Error: #{error.message}"
|
136
|
+
else
|
137
|
+
warn "Internal error: #{error.message}"
|
138
|
+
warn error.backtrace.join("\n") if @options[:verbose]
|
139
|
+
end
|
140
|
+
exit 1
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMaat
|
4
|
+
# Represents a single change/modification record from VCS
|
5
|
+
# This is the fundamental data structure that flows through the entire pipeline
|
6
|
+
class ChangeRecord
|
7
|
+
attr_reader :entity, :author, :date, :revision, :message, :loc_added, :loc_deleted
|
8
|
+
|
9
|
+
def initialize(entity:, author:, date:, revision:, message: nil, loc_added: nil, loc_deleted: nil)
|
10
|
+
@entity = entity
|
11
|
+
@author = author
|
12
|
+
@date = date.is_a?(Date) ? date : Date.parse(date)
|
13
|
+
@revision = revision
|
14
|
+
@message = message
|
15
|
+
@loc_added = loc_added.to_i if loc_added && (!loc_added.is_a?(Float) || !loc_added.nan?)
|
16
|
+
@loc_deleted = loc_deleted.to_i if loc_deleted && (!loc_deleted.is_a?(Float) || !loc_deleted.nan?)
|
17
|
+
end
|
18
|
+
|
19
|
+
def to_h
|
20
|
+
{
|
21
|
+
entity: entity,
|
22
|
+
author: author,
|
23
|
+
date: date,
|
24
|
+
revision: revision,
|
25
|
+
message: message,
|
26
|
+
loc_added: loc_added,
|
27
|
+
loc_deleted: loc_deleted
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
def ==(other)
|
32
|
+
other.is_a?(ChangeRecord) &&
|
33
|
+
entity == other.entity &&
|
34
|
+
author == other.author &&
|
35
|
+
date == other.date &&
|
36
|
+
revision == other.revision
|
37
|
+
end
|
38
|
+
|
39
|
+
def hash
|
40
|
+
[entity, author, date, revision].hash
|
41
|
+
end
|
42
|
+
|
43
|
+
def eql?(other)
|
44
|
+
self == other
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|