archimate-diff 0.1.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +22 -0
  3. data/.projectile +18 -0
  4. data/.rubocop.yml +13 -0
  5. data/.travis.yml +4 -0
  6. data/.yardocs +15 -0
  7. data/Gemfile +5 -0
  8. data/Guardfile +27 -0
  9. data/LICENSE +201 -0
  10. data/README.md +94 -0
  11. data/Rakefile +29 -0
  12. data/archimate-diff.gemspec +51 -0
  13. data/exe/archidiff +7 -0
  14. data/exe/archidiff-summary +7 -0
  15. data/exe/archimerge +7 -0
  16. data/exe/fmtxml +7 -0
  17. data/lib/archimate-diff.rb +33 -0
  18. data/lib/archimate/diff/archimate_array_reference.rb +113 -0
  19. data/lib/archimate/diff/archimate_identified_node_reference.rb +41 -0
  20. data/lib/archimate/diff/archimate_node_attribute_reference.rb +70 -0
  21. data/lib/archimate/diff/archimate_node_reference.rb +80 -0
  22. data/lib/archimate/diff/change.rb +49 -0
  23. data/lib/archimate/diff/cli/conflict_resolver.rb +41 -0
  24. data/lib/archimate/diff/cli/diff.rb +33 -0
  25. data/lib/archimate/diff/cli/diff_summary.rb +103 -0
  26. data/lib/archimate/diff/cli/merge.rb +51 -0
  27. data/lib/archimate/diff/cli/merger.rb +111 -0
  28. data/lib/archimate/diff/conflict.rb +31 -0
  29. data/lib/archimate/diff/conflicts.rb +89 -0
  30. data/lib/archimate/diff/conflicts/base_conflict.rb +53 -0
  31. data/lib/archimate/diff/conflicts/deleted_items_child_updated_conflict.rb +30 -0
  32. data/lib/archimate/diff/conflicts/deleted_items_referenced_conflict.rb +63 -0
  33. data/lib/archimate/diff/conflicts/path_conflict.rb +51 -0
  34. data/lib/archimate/diff/delete.rb +41 -0
  35. data/lib/archimate/diff/difference.rb +113 -0
  36. data/lib/archimate/diff/insert.rb +43 -0
  37. data/lib/archimate/diff/merge.rb +70 -0
  38. data/lib/archimate/diff/move.rb +51 -0
  39. data/lib/archimate/diff/version.rb +6 -0
  40. metadata +453 -0
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ module Archimate
3
+ module Diff
4
+ class Conflicts
5
+ # BaseConflict
6
+ # @abstract
7
+ class BaseConflict
8
+ def initialize(base_local_diffs, base_remote_diffs)
9
+ @base_local_diffs = base_local_diffs
10
+ @base_remote_diffs = base_remote_diffs
11
+ @associative = false
12
+ @diff_iterations = nil
13
+ end
14
+
15
+ def filter1
16
+ ->(_diff) { true }
17
+ end
18
+
19
+ def filter2
20
+ ->(_diff) { true }
21
+ end
22
+
23
+ def conflicts
24
+ progressbar = ProgressIndicator.new(total: diff_iterations.size, title: "Analyzing Conflicts")
25
+ diff_iterations.each_with_object([]) do |(md1, md2), conflicts|
26
+ progressbar.increment
27
+ conflicts.concat(
28
+ md1.map { |diff1| [diff1, md2.select(&method(:diff_conflicts).curry[diff1])] }
29
+ .reject { |_diff1, diff2| diff2.empty? }
30
+ .map { |diff1, diff2_ary| Conflict.new(diff1, diff2_ary, describe) }
31
+ )
32
+ end
33
+ ensure
34
+ progressbar.finish
35
+ end
36
+
37
+ def diff_combinations
38
+ combos = [@base_local_diffs, @base_remote_diffs]
39
+ @associative ? [combos] : combos.permutation(2)
40
+ end
41
+
42
+ # By default our conflict tests are not associative to we need to run
43
+ # [local, remote] and [remote, local] through the tests.
44
+ def diff_iterations
45
+ @diff_iterations ||=
46
+ diff_combinations.map do |local_diffs, remote_diffs|
47
+ [local_diffs.select(&filter1), remote_diffs.select(&filter2)]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ module Archimate
3
+ module Diff
4
+ class Conflicts
5
+ class DeletedItemsChildUpdatedConflict < BaseConflict
6
+ def describe
7
+ "Checking for Deleted items in one change set have children that are inserted or changed in the other change set"
8
+ end
9
+
10
+ def filter1
11
+ ->(diff) { diff.delete? }
12
+ end
13
+
14
+ def filter2
15
+ ->(diff) { !diff.delete? }
16
+ end
17
+
18
+ # TODO: This is simple, but might be slow.
19
+ def diff_conflicts(diff1, diff2)
20
+ da1 = diff1.path.split("/")
21
+ da2 = diff2.path.split("/")
22
+
23
+ cmp_size = [da1, da2].map(&:size).min - 1
24
+ return false if da2.size == cmp_size + 1
25
+ da1[0..cmp_size] == da2[0..cmp_size]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+ module Archimate
3
+ module Diff
4
+ class Conflicts
5
+ # DeletedItemsReferencedConflict
6
+ #
7
+ # This sort of conflict occurs when one change set has deleted an element
8
+ # that is referenced by id in the other change set.
9
+ #
10
+ # For example:
11
+ #
12
+ # In the local change set, an element with id "abc123" is deleted.
13
+ # In the remote change set, a child is inserted into a diagram with
14
+ # archimate_element = "abc123". These two changes are in conflict.
15
+ class DeletedItemsReferencedConflict < BaseConflict
16
+ using DataModel::DiffableArray
17
+ using DataModel::DiffablePrimitive
18
+
19
+ def describe
20
+ "Checking for Deleted items in one change set are referenced in the other change set"
21
+ end
22
+
23
+ # Filters a changeset to potentially conflicting diffs (making the set
24
+ # of combinations to check smaller)
25
+ #
26
+ # @return [lambda] a filter to limit diffs to Delete type
27
+ def filter1
28
+ ->(diff) { diff.delete? && diff.target.value.is_a?(DataModel::Referenceable) }
29
+ end
30
+
31
+ # Filters a changeset to potentially conflicting diffs (making the set
32
+ # of combinations to check smaller)
33
+ #
34
+ # @return [lambda] a filter to limit diffs to other
35
+ # than Delete type
36
+ def filter2
37
+ ->(diff) { !diff.delete? }
38
+ end
39
+
40
+ # Determine the set of conflicts between the given diffs
41
+ # def conflicts
42
+ # progressbar = ProgressIndicator.new(total: diff_iterations.size)
43
+ # diff_iterations.each_with_object([]) do |(md1, md2), a|
44
+ # progressbar.increment
45
+ # a.concat(
46
+ # md1.map { |diff1| [diff1, md2.select(&method(:diff_conflicts).curry[diff1])] }
47
+ # .reject { |_diff1, diff2| diff2.empty? }
48
+ # .map { |diff1, diff2_ary| Conflict.new(diff1, diff2_ary, describe) }
49
+ # )
50
+ # end
51
+ # ensure
52
+ # progressbar&.finish
53
+ # end
54
+
55
+ # TODO: This is simple, but might be slow. If it is, then override
56
+ # the conflicts method to prevent calculating referenced_identified_nodes methods
57
+ def diff_conflicts(diff1, diff2)
58
+ diff2.target.value.referenced_identified_nodes.include?(diff1.target.value.id)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ module Archimate
3
+ module Diff
4
+ class Conflicts
5
+ class PathConflict < BaseConflict
6
+ def initialize(base_local_diffs, base_remote_diffs)
7
+ super
8
+ @associative = true
9
+ end
10
+
11
+ def describe
12
+ "Checking for Differences in one change set that conflict with changes in other change set at the same path"
13
+ end
14
+
15
+ def diff_conflicts(diff1, diff2)
16
+ same_path_but_diff(diff1, diff2) && in_conflict?(diff1, diff2)
17
+ end
18
+
19
+ private
20
+
21
+ def same_path_but_diff(a, b)
22
+ a.path == b.path && a != b
23
+ end
24
+
25
+ # I'm in conflict if:
26
+ # 1. ld and rd are both changes to the same path (but not the same change)
27
+ # 2. one is a change, the other a delete and changed_from is the same
28
+ # 3. both are inserts of the different identifyable nodes with the same id
29
+ #
30
+ # If I'm not an identifyable node and my parent is an array, then two inserts are ok
31
+ def in_conflict?(local_diff, remote_diff)
32
+ return true if
33
+ local_diff.target.parent.is_a?(Array) &&
34
+ local_diff.target.value.is_a?(DataModel::Referenceable) &&
35
+ local_diff.target.value.id == remote_diff.target.value.id &&
36
+ local_diff != remote_diff
37
+
38
+ case [local_diff, remote_diff].map { |d| d.class.name.split('::').last }.sort
39
+ when %w(Change Change)
40
+ local_diff.changed_from.value == remote_diff.changed_from.value &&
41
+ local_diff.target.value != remote_diff.target.value
42
+ when %w(Change Delete)
43
+ true
44
+ else
45
+ false
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ module Archimate
3
+ module Diff
4
+ class Delete < Difference
5
+ using DataModel::DiffablePrimitive
6
+ using DataModel::DiffableArray
7
+
8
+ # Create a new Delete difference
9
+ #
10
+ # @param target [ArchimateNodeReference] Element that was deleted
11
+ def initialize(target)
12
+ super
13
+ end
14
+
15
+ def to_s
16
+ # Note - the explicit to_s is required to access the DiffableArray
17
+ # implementation if the parent is an Array.
18
+ "#{diff_type} #{target} from #{target.parent.to_s}"
19
+ end
20
+
21
+ def apply(el)
22
+ target.delete(el)
23
+ el
24
+ end
25
+
26
+ def delete?
27
+ true
28
+ end
29
+
30
+ def kind
31
+ "Delete"
32
+ end
33
+
34
+ private
35
+
36
+ def diff_type
37
+ Color.color('DELETE:', :delete)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Archimate
6
+ module Diff
7
+ class Difference
8
+ using DataModel::DiffableArray
9
+ using DataModel::DiffablePrimitive
10
+ extend Forwardable
11
+
12
+ ARRAY_RE = Regexp.compile(/\[(\d+)\]/)
13
+ PATH_ROOT_SORT_ORDER = %w[elements relationships diagrams organizations].freeze
14
+
15
+ # delete: something responds to parent, child attribute (which is thing deleted - and could be sym for archimate nodes or index for array), value
16
+ # insert: something responds to parent, child attribute (or index), value, after value (to help with inserts)
17
+ # change: something responds to parent, child attribute (or index), value, changed from value
18
+ # move: something responds to parent, child index, value, after value) move after a particular value
19
+ attr_reader :target
20
+ attr_reader :changed_from
21
+
22
+ def_delegator :@target, :path
23
+
24
+ # Re-thinking.
25
+ #
26
+ # Requirements:
27
+ #
28
+ # 1. User friendly display of what is different in context
29
+ # 2. Able to apply the diff to another model (which was based on the "base" of the diff)
30
+ #
31
+ # Delete: example
32
+ # ArchimateNode child.bounds
33
+ # ArchimateNode, attribute model, "name"
34
+ # DiffableArray, ArchimateNode model.elements, element
35
+ # bendpoint attributes under connection
36
+ # documentation
37
+ # properties
38
+ # child/style/fill_color
39
+ # child/style/font/name
40
+ #
41
+ # @param target [Dry::Struct with id attribute] the element operated on (why is array treated as a special case?)
42
+ # @param changed_from [same class as target] (optional) for change this is the previous value
43
+ # def initialize(changed_from, target)
44
+ def initialize(target, changed_from = nil)
45
+ # raise TypeError, "Expected target to be an ArchimateNodeReference" unless target.is_a?(ArchimateNodeReference)
46
+ @target = target
47
+ @changed_from = changed_from
48
+ end
49
+
50
+ def ==(other)
51
+ other.is_a?(self.class) &&
52
+ @target == other.target &&
53
+ @changed_from == other.changed_from
54
+ end
55
+
56
+ # Difference sorting is based on the path.
57
+ # Top level components are sorted in this order: (elements, relationships, diagrams, organizations)
58
+ # Array entries are sorted by numeric order
59
+ # Others are sorted alphabetically
60
+ # TODO: this isn't complete
61
+ def <=>(other)
62
+ a = path_to_array
63
+ b = other.path_to_array
64
+
65
+ part_a = a.shift
66
+ part_b = b.shift
67
+ res = PATH_ROOT_SORT_ORDER.index(part_a) <=> PATH_ROOT_SORT_ORDER.index(part_b)
68
+ return res unless res.zero?
69
+
70
+ until a.empty? || b.empty?
71
+ part_a = a.shift
72
+ part_b = b.shift
73
+
74
+ return part_a <=> part_b unless (part_a <=> part_b).zero?
75
+ end
76
+
77
+ return -1 if a.empty?
78
+ return 1 if b.empty?
79
+ part_a <=> part_b
80
+ end
81
+
82
+ def delete?
83
+ false
84
+ end
85
+
86
+ def change?
87
+ false
88
+ end
89
+
90
+ def insert?
91
+ false
92
+ end
93
+
94
+ def move?
95
+ false
96
+ end
97
+
98
+ def path_to_array
99
+ path(force_array_index: :index).split("/").map do |p|
100
+ md = ARRAY_RE.match(p)
101
+ md ? md[1].to_i : p
102
+ end
103
+ end
104
+
105
+ def summary_element
106
+ summary_elements = [DataModel::Element, DataModel::Organization, DataModel::Relationship, DataModel::Diagram, DataModel::Model]
107
+ se = target.value.primitive? ? target.parent : target.value
108
+ se = se.parent while summary_elements.none? { |c| se.is_a?(c) }
109
+ se
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+ module Archimate
3
+ module Diff
4
+ class Insert < Difference
5
+ using DataModel::DiffableArray
6
+
7
+ # Create a new Insert difference
8
+ #
9
+ # @param inserted_element [Archimate::DataModel::ArchimateNode] Element
10
+ # that was inserted
11
+ # @param sub_path [str] Path under inserted_element for primitive values
12
+ def initialize(target)
13
+ super
14
+ end
15
+
16
+ def to_s
17
+ # Note - the explicit to_s is required to access the DiffableArray
18
+ # implementation if the parent is an Array.
19
+ "#{diff_type} #{target} into #{target.parent.to_s}"
20
+ end
21
+
22
+ def apply(to_model)
23
+ throw TypeError, "Expected a Archimate::DataModel::Model, was a #{to_model.class}" unless to_model.is_a?(DataModel::Model)
24
+ target.insert(to_model)
25
+ to_model
26
+ end
27
+
28
+ def insert?
29
+ true
30
+ end
31
+
32
+ def kind
33
+ "Insert"
34
+ end
35
+
36
+ private
37
+
38
+ def diff_type
39
+ Color.color('INSERT:', :insert)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parallel"
4
+
5
+ module Archimate
6
+ module Diff
7
+ class Merge
8
+ include Archimate::Logging
9
+ using DataModel::DiffableArray
10
+
11
+ def three_way(base, local, remote)
12
+ debug { "Computing base:local & base:remote diffs" }
13
+ base_local_diffs, base_remote_diffs = Parallel.map([[base, local], [base, remote]],
14
+ in_processes: 2) do |base_model, other_model|
15
+ base_model.diff(other_model)
16
+ end
17
+
18
+ debug "Finding Conflicts in #{base_local_diffs.size + base_remote_diffs.size} diffs"
19
+ conflicts = Conflicts.new(base_local_diffs, base_remote_diffs)
20
+ resolved_diffs = conflicts.resolve
21
+
22
+ [apply_diffs(resolved_diffs, base.clone), conflicts]
23
+ end
24
+
25
+ # Applies the set of diffs to the model returning a
26
+ # new model with the diffs applied.
27
+ def apply_diffs(diffs, model)
28
+ debug { "Applying #{diffs.size} diffs" }
29
+ progressbar = ProgressIndicator.new(total: diffs.size, title: "Applying diffs")
30
+ diffs
31
+ .inject(model) do |model_a, diff|
32
+ progressbar.increment
33
+ diff.apply(model_a)
34
+ end
35
+ .rebuild_index
36
+ .organize
37
+ ensure
38
+ progressbar&.finish
39
+ end
40
+
41
+ # # TODO: not currently used
42
+ # def find_merged_duplicates
43
+ # [@base_local_diffs, @base_remote_diffs].map do |diffs|
44
+ # deleted_element_diffs = diffs.select(&:delete?).select(&:element?)
45
+ # deleted_element_diffs.each_with_object({}) do |diff, a|
46
+ # element = diff.from_model.elements[diff.element_idx]
47
+ # found = diff.from_model.elements.select do |el|
48
+ # el != element && el.type == element.type && el.name == element.name
49
+ # end
50
+ # next if found.empty?
51
+ # a[diff] = found
52
+ # debug { "\nFound potential de-duplication:" }
53
+ # debug { "\t#{diff}" }
54
+ # debug { "Might be replaced with:\n\t#{found.map(&:to_s).join(" }\n\t")}\n\n"
55
+ # end
56
+ # end
57
+ # end
58
+
59
+ # # TODO: not currently used
60
+ # def filter_path_conflicts(diffs)
61
+ # diffs.sort { |a, b| a.path_to_array.size <=> b.path_to_array.size }.each_with_object([]) do |i, e|
62
+ # diffs.delete(i)
63
+ # path_conflicts = diffs.select { |d| d.path.start_with?(i.path) }
64
+ # path_conflicts.each { |d| diffs.delete(d) }
65
+ # e << i
66
+ # end
67
+ # end
68
+ end
69
+ end
70
+ end