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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.commitlintrc.json +44 -0
  3. data/.mailmap +3 -0
  4. data/.overcommit.yml +77 -0
  5. data/.release-please-config.json +33 -0
  6. data/.release-please-manifest.json +3 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +48 -0
  9. data/CHANGELOG.md +46 -0
  10. data/CI_CD_SETUP.md +180 -0
  11. data/CLAUDE.md +130 -0
  12. data/Dockerfile +40 -0
  13. data/README.md +444 -0
  14. data/README_RUBY.md +300 -0
  15. data/RELEASE_PLEASE_SETUP.md +198 -0
  16. data/RUBY_MAAT.md +227 -0
  17. data/Rakefile +12 -0
  18. data/doc/imgs/abs_churn_sample.png +0 -0
  19. data/doc/imgs/code_age_sample.png +0 -0
  20. data/doc/imgs/coupling_sample.png +0 -0
  21. data/doc/imgs/crime_cover.jpg +0 -0
  22. data/doc/imgs/tree_map_sample.png +0 -0
  23. data/doc/intro.md +3 -0
  24. data/exe/ruby-maat +6 -0
  25. data/lib/ruby_maat/analysis/authors.rb +47 -0
  26. data/lib/ruby_maat/analysis/base_analysis.rb +70 -0
  27. data/lib/ruby_maat/analysis/churn.rb +255 -0
  28. data/lib/ruby_maat/analysis/code_age.rb +53 -0
  29. data/lib/ruby_maat/analysis/commit_messages.rb +58 -0
  30. data/lib/ruby_maat/analysis/communication.rb +56 -0
  31. data/lib/ruby_maat/analysis/effort.rb +150 -0
  32. data/lib/ruby_maat/analysis/entities.rb +40 -0
  33. data/lib/ruby_maat/analysis/identity.rb +12 -0
  34. data/lib/ruby_maat/analysis/logical_coupling.rb +134 -0
  35. data/lib/ruby_maat/analysis/sum_of_coupling.rb +43 -0
  36. data/lib/ruby_maat/analysis/summary.rb +43 -0
  37. data/lib/ruby_maat/app.rb +143 -0
  38. data/lib/ruby_maat/change_record.rb +47 -0
  39. data/lib/ruby_maat/cli.rb +187 -0
  40. data/lib/ruby_maat/dataset.rb +205 -0
  41. data/lib/ruby_maat/groupers/layer_grouper.rb +67 -0
  42. data/lib/ruby_maat/groupers/team_mapper.rb +51 -0
  43. data/lib/ruby_maat/groupers/time_grouper.rb +70 -0
  44. data/lib/ruby_maat/output/csv_output.rb +65 -0
  45. data/lib/ruby_maat/parsers/base_parser.rb +63 -0
  46. data/lib/ruby_maat/parsers/git2_parser.rb +72 -0
  47. data/lib/ruby_maat/parsers/git_parser.rb +66 -0
  48. data/lib/ruby_maat/parsers/mercurial_parser.rb +64 -0
  49. data/lib/ruby_maat/parsers/perforce_parser.rb +77 -0
  50. data/lib/ruby_maat/parsers/svn_parser.rb +76 -0
  51. data/lib/ruby_maat/parsers/tfs_parser.rb +103 -0
  52. data/lib/ruby_maat/version.rb +5 -0
  53. data/lib/ruby_maat.rb +44 -0
  54. 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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyMaat
4
+ module Analysis
5
+ # Identity analysis - debugging analysis that just returns raw data
6
+ class Identity < BaseAnalysis
7
+ def analyze(dataset, _options = {})
8
+ dataset.to_df
9
+ end
10
+ end
11
+ end
12
+ 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