philiprehberger-differ 0.2.8 → 0.4.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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +34 -0
- data/lib/philiprehberger/differ/changeset.rb +40 -0
- data/lib/philiprehberger/differ/version.rb +1 -1
- data/lib/philiprehberger/differ.rb +91 -0
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e8573d84ddf309e3d702cb9b90d1b407c8963cd84cf62235e6620d586949437
|
|
4
|
+
data.tar.gz: 79ddab362109ba0b8b7958ba77fe9242426a403904082e81b2c8db2f6246a2c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 585f857fb63da7b81aad2ded9a2948e4cf27b5d996c6afa76480a0ff41397d3181ab02cad53ab285a8d629f15df669c6372d2d7d220b68d27168dac72f8993be
|
|
7
|
+
data.tar.gz: 20e291749bdbec14d58031833b02c9de65dc5f40bd3675407b478ce8dce20efeeb613ca9e28062b97628fd8a32cf44eb98df5e781964b5f70ac85fe1a09a6618
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-04-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Changeset` now includes `Enumerable` for direct iteration over changes
|
|
14
|
+
- `Changeset#count` returning the number of changes
|
|
15
|
+
- `Changeset#paths` returning all changed paths
|
|
16
|
+
- `Changeset#include?(path)` to check if a specific path was changed
|
|
17
|
+
- `Changeset#summary` returning `{ added:, removed:, changed: }` counts
|
|
18
|
+
|
|
19
|
+
## [0.3.0] - 2026-04-01
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- `Differ.subset(changeset, path)` for filtering changes by path prefix
|
|
23
|
+
- `Differ.merge(base, theirs, ours)` for three-way merge with conflict detection
|
|
24
|
+
- `Differ.breaking_changes?(changeset)` for detecting removals and type changes
|
|
25
|
+
|
|
26
|
+
## [0.2.9] - 2026-03-31
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- Add GitHub issue templates, dependabot config, and PR template
|
|
30
|
+
|
|
10
31
|
## [0.2.8] - 2026-03-31
|
|
11
32
|
|
|
12
33
|
### Changed
|
data/README.md
CHANGED
|
@@ -116,6 +116,29 @@ new_data = { users: [{ id: 2, name: 'Bobby' }, { id: 1, name: 'Alice' }] }
|
|
|
116
116
|
changeset = Philiprehberger::Differ.diff(old_data, new_data, array_key: :id)
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
### Subset Filtering
|
|
120
|
+
|
|
121
|
+
Filter a diff to only changes under a specific path:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
changeset = Philiprehberger::Differ.diff(old_data, new_data)
|
|
125
|
+
user_changes = Philiprehberger::Differ.subset(changeset, 'user')
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Three-Way Merge
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
result = Philiprehberger::Differ.merge(base, theirs, ours)
|
|
132
|
+
result[:merged] # => merged hash
|
|
133
|
+
result[:conflicts] # => [{ path:, theirs:, ours: }]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Breaking Change Detection
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
Philiprehberger::Differ.breaking_changes?(changeset) # => true/false
|
|
140
|
+
```
|
|
141
|
+
|
|
119
142
|
## API
|
|
120
143
|
|
|
121
144
|
### `Philiprehberger::Differ.diff(old_val, new_val, ignore: [], array_key: nil)`
|
|
@@ -131,6 +154,11 @@ Returns a Float between 0.0 (completely different) and 1.0 (identical).
|
|
|
131
154
|
| Method | Description |
|
|
132
155
|
|---|---|
|
|
133
156
|
| `changed?` | Returns `true` if any differences exist |
|
|
157
|
+
| `count` | Number of changes |
|
|
158
|
+
| `paths` | Array of all changed paths |
|
|
159
|
+
| `include?(path)` | Check if a specific path was changed |
|
|
160
|
+
| `summary` | `{ added:, removed:, changed: }` counts |
|
|
161
|
+
| `each` | Iterate over changes (includes `Enumerable`) |
|
|
134
162
|
| `changes` | All `Change` objects |
|
|
135
163
|
| `added` | Changes where `type == :added` |
|
|
136
164
|
| `removed` | Changes where `type == :removed` |
|
|
@@ -141,6 +169,12 @@ Returns a Float between 0.0 (completely different) and 1.0 (identical).
|
|
|
141
169
|
| `to_text` | Human-readable text with +/- prefixes |
|
|
142
170
|
| `to_json_patch` | Array of RFC 6902 JSON Patch operations |
|
|
143
171
|
|
|
172
|
+
| Method | Description |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `Differ.subset(changeset, path)` | Filter changes to a specific path prefix |
|
|
175
|
+
| `Differ.merge(base, theirs, ours)` | Three-way merge with conflict detection |
|
|
176
|
+
| `Differ.breaking_changes?(changeset)` | Detect removals and type changes |
|
|
177
|
+
|
|
144
178
|
### `Change`
|
|
145
179
|
|
|
146
180
|
| Attribute | Description |
|
|
@@ -3,12 +3,52 @@
|
|
|
3
3
|
module Philiprehberger
|
|
4
4
|
module Differ
|
|
5
5
|
class Changeset
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
6
8
|
attr_reader :changes
|
|
7
9
|
|
|
8
10
|
def initialize(changes = [])
|
|
9
11
|
@changes = changes
|
|
10
12
|
end
|
|
11
13
|
|
|
14
|
+
# Iterate over each Change.
|
|
15
|
+
#
|
|
16
|
+
# @yield [Change] each change
|
|
17
|
+
# @return [Enumerator] if no block given
|
|
18
|
+
def each(&)
|
|
19
|
+
@changes.each(&)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Number of changes.
|
|
23
|
+
#
|
|
24
|
+
# @return [Integer]
|
|
25
|
+
def count
|
|
26
|
+
@changes.length
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Array of all changed paths.
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<String>]
|
|
32
|
+
def paths
|
|
33
|
+
@changes.map(&:path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check if a specific path was changed.
|
|
37
|
+
#
|
|
38
|
+
# @param path [String, Symbol] the path to check
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def include?(path)
|
|
41
|
+
path_s = path.to_s
|
|
42
|
+
@changes.any? { |c| c.path.to_s == path_s }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Summary counts by change type.
|
|
46
|
+
#
|
|
47
|
+
# @return [Hash{Symbol => Integer}] { added:, removed:, changed: }
|
|
48
|
+
def summary
|
|
49
|
+
{ added: added.length, removed: removed.length, changed: changed.length }
|
|
50
|
+
end
|
|
51
|
+
|
|
12
52
|
def changed?
|
|
13
53
|
!@changes.empty?
|
|
14
54
|
end
|
|
@@ -19,5 +19,96 @@ module Philiprehberger
|
|
|
19
19
|
def self.similarity(old_val, new_val, ignore: [], array_key: nil)
|
|
20
20
|
Similarity.call(old_val, new_val, ignore: ignore, array_key: array_key)
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
# Filter changeset to only changes under a specific path prefix
|
|
24
|
+
#
|
|
25
|
+
# @param changeset [Changeset] the changeset to filter
|
|
26
|
+
# @param path [String] the path prefix to filter by
|
|
27
|
+
# @return [Changeset] new changeset with only matching changes
|
|
28
|
+
def self.subset(changeset, path)
|
|
29
|
+
prefix = path.to_s
|
|
30
|
+
filtered = changeset.changes.select do |change|
|
|
31
|
+
change.path.to_s == prefix || change.path.to_s.start_with?("#{prefix}.")
|
|
32
|
+
end
|
|
33
|
+
Changeset.new(filtered)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Perform a three-way merge with conflict detection
|
|
37
|
+
#
|
|
38
|
+
# @param base [Hash] the common ancestor
|
|
39
|
+
# @param theirs [Hash] their changes
|
|
40
|
+
# @param ours [Hash] our changes
|
|
41
|
+
# @return [Hash] { merged: Hash, conflicts: Array }
|
|
42
|
+
def self.merge(base, theirs, ours)
|
|
43
|
+
their_changes = Comparator.call(base, theirs)
|
|
44
|
+
our_changes = Comparator.call(base, ours)
|
|
45
|
+
|
|
46
|
+
conflicts = detect_conflicts(their_changes, our_changes)
|
|
47
|
+
conflict_paths = conflicts.map { |c| c[:path] }
|
|
48
|
+
|
|
49
|
+
merged = deep_dup(base)
|
|
50
|
+
(their_changes + our_changes).each do |change|
|
|
51
|
+
next if conflict_paths.include?(change.path)
|
|
52
|
+
|
|
53
|
+
apply_merge_change(merged, change)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
{ merged: merged, conflicts: conflicts }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Detect if a changeset contains breaking changes (removals or type changes)
|
|
60
|
+
#
|
|
61
|
+
# @param changeset [Changeset] the changeset to check
|
|
62
|
+
# @return [Boolean] true if breaking changes are detected
|
|
63
|
+
def self.breaking_changes?(changeset)
|
|
64
|
+
changeset.changes.any? do |change|
|
|
65
|
+
change.type == :removed ||
|
|
66
|
+
(change.type == :changed && change.old_value.class != change.new_value.class)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.detect_conflicts(their_changes, our_changes)
|
|
71
|
+
their_paths = their_changes.to_h { |c| [c.path, c] }
|
|
72
|
+
our_paths = our_changes.to_h { |c| [c.path, c] }
|
|
73
|
+
|
|
74
|
+
conflicts = []
|
|
75
|
+
their_paths.each do |path, their_change|
|
|
76
|
+
our_change = our_paths[path]
|
|
77
|
+
next unless our_change
|
|
78
|
+
|
|
79
|
+
if their_change.new_value != our_change.new_value
|
|
80
|
+
conflicts << { path: path, theirs: their_change.new_value, ours: our_change.new_value }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
conflicts
|
|
84
|
+
end
|
|
85
|
+
private_class_method :detect_conflicts
|
|
86
|
+
|
|
87
|
+
def self.apply_merge_change(hash, change)
|
|
88
|
+
keys = change.path.to_s.split('.')
|
|
89
|
+
target = hash
|
|
90
|
+
keys[0..-2].each do |k|
|
|
91
|
+
k = k.to_sym if target.is_a?(Hash) && target.key?(k.to_sym)
|
|
92
|
+
target = target[k]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
key = keys.last
|
|
96
|
+
key = key.to_sym if target.is_a?(Hash) && (target.key?(key.to_sym) || target.keys.any?(Symbol))
|
|
97
|
+
|
|
98
|
+
case change.type
|
|
99
|
+
when :added, :changed then target[key] = change.new_value
|
|
100
|
+
when :removed then target.delete(key)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
private_class_method :apply_merge_change
|
|
104
|
+
|
|
105
|
+
def self.deep_dup(obj)
|
|
106
|
+
case obj
|
|
107
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
|
|
108
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
109
|
+
else obj
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
private_class_method :deep_dup
|
|
22
113
|
end
|
|
23
114
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: philiprehberger-differ
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Philip Rehberger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-04-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A Ruby library for deep structural diffing of hashes, arrays, and nested
|
|
14
14
|
objects with apply/revert, JSON Patch output, similarity scoring, and configurable
|
|
@@ -29,11 +29,11 @@ files:
|
|
|
29
29
|
- lib/philiprehberger/differ/formatters.rb
|
|
30
30
|
- lib/philiprehberger/differ/similarity.rb
|
|
31
31
|
- lib/philiprehberger/differ/version.rb
|
|
32
|
-
homepage: https://
|
|
32
|
+
homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-differ
|
|
33
33
|
licenses:
|
|
34
34
|
- MIT
|
|
35
35
|
metadata:
|
|
36
|
-
homepage_uri: https://
|
|
36
|
+
homepage_uri: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-differ
|
|
37
37
|
source_code_uri: https://github.com/philiprehberger/rb-differ
|
|
38
38
|
changelog_uri: https://github.com/philiprehberger/rb-differ/blob/main/CHANGELOG.md
|
|
39
39
|
bug_tracker_uri: https://github.com/philiprehberger/rb-differ/issues
|