json-schema-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.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Schema
5
+ module Diff
6
+ class Comparer
7
+ def initialize(schema_parser, ignore_fields = [])
8
+ @schema_parser = schema_parser
9
+ @ignore_fields = ignore_fields.map(&:to_s)
10
+ end
11
+
12
+ def compare(old_json, new_json)
13
+ changes = []
14
+ compare_recursive(old_json, new_json, "", changes)
15
+ changes
16
+ end
17
+
18
+ private
19
+
20
+ def compare_recursive(old_val, new_val, path, changes)
21
+ # Skip ignored fields
22
+ return if @ignore_fields.include?(path)
23
+
24
+ field_info = @schema_parser.get_field_info(path)
25
+
26
+ # Skip read-only fields
27
+ return if field_info[:read_only]
28
+
29
+ if old_val.nil? && !new_val.nil?
30
+ add_change(changes, path, old_val, new_val, "addition", field_info)
31
+ elsif !old_val.nil? && new_val.nil?
32
+ add_change(changes, path, old_val, new_val, "removal", field_info)
33
+ elsif old_val.class != new_val.class
34
+ add_change(changes, path, old_val, new_val, "type_change", field_info)
35
+ elsif old_val.is_a?(Hash) && new_val.is_a?(Hash)
36
+ compare_objects(old_val, new_val, path, changes)
37
+ elsif old_val.is_a?(Array) && new_val.is_a?(Array)
38
+ compare_arrays(old_val, new_val, path, changes)
39
+ elsif old_val != new_val
40
+ add_change(changes, path, old_val, new_val, "modification", field_info)
41
+ end
42
+ end
43
+
44
+ def compare_objects(old_obj, new_obj, path, changes)
45
+ all_keys = (old_obj.keys + new_obj.keys).uniq
46
+
47
+ all_keys.each do |key|
48
+ key_path = path.empty? ? key : "#{path}.#{key}"
49
+ compare_recursive(old_obj[key], new_obj[key], key_path, changes)
50
+ end
51
+ end
52
+
53
+ def compare_arrays(old_arr, new_arr, path, changes)
54
+ max_length = [old_arr.length, new_arr.length].max
55
+
56
+ (0...max_length).each do |index|
57
+ index_path = "#{path}[#{index}]"
58
+ old_item = index < old_arr.length ? old_arr[index] : nil
59
+ new_item = index < new_arr.length ? new_arr[index] : nil
60
+ compare_recursive(old_item, new_item, index_path, changes)
61
+ end
62
+ end
63
+
64
+ def add_change(changes, path, old_val, new_val, change_type, field_info)
65
+ # Check if this is a noisy field
66
+ is_noisy = @schema_parser.is_noisy_field?(path, old_val) ||
67
+ @schema_parser.is_noisy_field?(path, new_val)
68
+
69
+ change = {
70
+ path: path,
71
+ old_value: old_val,
72
+ new_value: new_val,
73
+ change_type: change_type,
74
+ field_info: field_info,
75
+ is_noisy: is_noisy
76
+ }
77
+
78
+ changes << change
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Schema
5
+ module Diff
6
+ class Formatter
7
+ COLORS = {
8
+ red: "\e[31m",
9
+ green: "\e[32m",
10
+ yellow: "\e[33m",
11
+ blue: "\e[34m",
12
+ magenta: "\e[35m",
13
+ cyan: "\e[36m",
14
+ reset: "\e[0m"
15
+ }.freeze
16
+
17
+ def initialize(format = "pretty", use_color = true)
18
+ @format = format
19
+ @use_color = use_color
20
+ end
21
+
22
+ def format(changes)
23
+ case @format
24
+ when "json"
25
+ format_json(changes)
26
+ else
27
+ format_pretty(changes)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def format_json(changes)
34
+ JSON.pretty_generate(changes.map do |change|
35
+ {
36
+ path: change[:path],
37
+ change_type: change[:change_type],
38
+ old_value: change[:old_value],
39
+ new_value: change[:new_value],
40
+ field_info: change[:field_info],
41
+ is_noisy: change[:is_noisy]
42
+ }
43
+ end)
44
+ end
45
+
46
+ def format_pretty(changes)
47
+ return "No changes detected." if changes.empty?
48
+
49
+ output = []
50
+ output << colorize("JSON Schema Diff Results", :cyan, bold: true)
51
+ output << "=" * 50
52
+
53
+ # Group changes by type
54
+ grouped = changes.group_by { |c| c[:change_type] }
55
+
56
+ %w[addition removal modification type_change].each do |type|
57
+ next unless grouped[type]
58
+
59
+ output << ""
60
+ output << colorize("#{type.upcase.tr('_', ' ')}S (#{grouped[type].length}):", type_color(type), bold: true)
61
+ output << ""
62
+
63
+ grouped[type].each do |change|
64
+ output.concat(format_change(change))
65
+ end
66
+ end
67
+
68
+ # Summary
69
+ output << ""
70
+ output << colorize("SUMMARY:", :blue, bold: true)
71
+ output << "Total changes: #{changes.length}"
72
+ noisy_count = changes.count { |c| c[:is_noisy] }
73
+ output << "Noisy fields: #{noisy_count}" if noisy_count > 0
74
+
75
+ output.join("\n")
76
+ end
77
+
78
+ def format_change(change)
79
+ lines = []
80
+ path = change[:path]
81
+ field_info = change[:field_info]
82
+
83
+ # Path and type info
84
+ path_line = " #{path}"
85
+ if field_info[:type]
86
+ path_line += " (#{field_info[:type]}"
87
+ path_line += ", #{field_info[:format]}" if field_info[:format]
88
+ path_line += ")"
89
+ end
90
+ path_line += colorize(" [noisy]", :yellow) if change[:is_noisy]
91
+ lines << path_line
92
+
93
+ # Title/description if available
94
+ if field_info[:title]
95
+ lines << " Title: #{field_info[:title]}"
96
+ end
97
+
98
+ # Show enum values if applicable
99
+ if field_info[:enum]
100
+ lines << " Allowed values: #{field_info[:enum].join(', ')}"
101
+ end
102
+
103
+ # Value changes
104
+ case change[:change_type]
105
+ when "addition"
106
+ lines << " #{colorize('+ Added:', :green)} #{format_value(change[:new_value])}"
107
+ when "removal"
108
+ lines << " #{colorize('- Removed:', :red)} #{format_value(change[:old_value])}"
109
+ when "modification", "type_change"
110
+ lines << " #{colorize('- Old:', :red)} #{format_value(change[:old_value])}"
111
+ lines << " #{colorize('+ New:', :green)} #{format_value(change[:new_value])}"
112
+ end
113
+
114
+ lines << ""
115
+ lines
116
+ end
117
+
118
+ def format_value(value)
119
+ case value
120
+ when String
121
+ "\"#{value}\""
122
+ when nil
123
+ "null"
124
+ else
125
+ value.to_s
126
+ end
127
+ end
128
+
129
+ def type_color(type)
130
+ case type
131
+ when "addition" then :green
132
+ when "removal" then :red
133
+ when "modification" then :yellow
134
+ when "type_change" then :magenta
135
+ else :blue
136
+ end
137
+ end
138
+
139
+ def colorize(text, color, bold: false)
140
+ return text unless @use_color
141
+
142
+ result = "#{COLORS[color]}#{text}#{COLORS[:reset]}"
143
+ result = "\e[1m#{result}" if bold
144
+ result
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Schema
5
+ module Diff
6
+ class SchemaParser
7
+ attr_reader :schema
8
+
9
+ def initialize(schema_file)
10
+ @schema = JSON.parse(File.read(schema_file))
11
+ rescue JSON::ParserError => e
12
+ raise Error, "Invalid JSON schema: #{e.message}"
13
+ rescue Errno::ENOENT => e
14
+ raise Error, "Schema file not found: #{e.message}"
15
+ end
16
+
17
+ def get_field_info(path)
18
+ field_schema = traverse_schema(path.split('.'))
19
+ return {} unless field_schema
20
+
21
+ {
22
+ type: field_schema["type"],
23
+ title: field_schema["title"],
24
+ description: field_schema["description"],
25
+ format: field_schema["format"],
26
+ enum: field_schema["enum"],
27
+ read_only: field_schema["readOnly"] || false
28
+ }
29
+ end
30
+
31
+ def is_noisy_field?(path, value)
32
+ field_info = get_field_info(path)
33
+ format = field_info[:format]
34
+
35
+ # Check for timestamp formats
36
+ return true if format == "date-time" || format == "date" || format == "time"
37
+
38
+ # Check for UUID format
39
+ return true if format == "uuid"
40
+
41
+ # Check for fields that look like UUIDs or timestamps
42
+ if value.is_a?(String)
43
+ return true if value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
44
+ return true if value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
45
+ end
46
+
47
+ false
48
+ end
49
+
50
+ private
51
+
52
+ def traverse_schema(path_parts)
53
+ current = @schema
54
+
55
+ path_parts.each do |part|
56
+ if current["type"] == "object" && current["properties"]
57
+ current = current["properties"][part]
58
+ return nil unless current
59
+ elsif current["type"] == "array" && current["items"]
60
+ current = current["items"]
61
+ else
62
+ return nil
63
+ end
64
+ end
65
+
66
+ current
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Schema
5
+ module Diff
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "diff/version"
6
+ require_relative "diff/cli"
7
+ require_relative "diff/schema_parser"
8
+ require_relative "diff/comparer"
9
+ require_relative "diff/formatter"
10
+
11
+ module Json
12
+ module Schema
13
+ module Diff
14
+ class Error < StandardError; end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ module Json
2
+ module Schema
3
+ module Diff
4
+ VERSION: String
5
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
6
+ end
7
+ end
8
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: json-schema-diff
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Nesbitt
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A Ruby gem that performs semantic diffs between JSON files, using JSON
13
+ Schema to guide and annotate the diff output with type information, field metadata,
14
+ and structured change detection.
15
+ email:
16
+ - andrewnez@gmail.com
17
+ executables:
18
+ - json-schema-diff
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - CODE_OF_CONDUCT.md
24
+ - CONTRIBUTING.md
25
+ - README.md
26
+ - Rakefile
27
+ - SECURITY.md
28
+ - examples/capslock/README.md
29
+ - examples/capslock/capslock-v0.5.0.json
30
+ - examples/capslock/capslock-v0.6.0.json
31
+ - examples/capslock/capslock.schema.json
32
+ - examples/generic/report-v1.2.0.json
33
+ - examples/generic/report-v1.3.0.json
34
+ - examples/generic/security-report.schema.json
35
+ - examples/zizmor/README.md
36
+ - examples/zizmor/zizmor-v0.1.0.json
37
+ - examples/zizmor/zizmor-v0.2.0.json
38
+ - examples/zizmor/zizmor.schema.json
39
+ - exe/json-schema-diff
40
+ - lib/json/schema/diff.rb
41
+ - lib/json/schema/diff/cli.rb
42
+ - lib/json/schema/diff/comparer.rb
43
+ - lib/json/schema/diff/formatter.rb
44
+ - lib/json/schema/diff/schema_parser.rb
45
+ - lib/json/schema/diff/version.rb
46
+ - sig/json/schema/diff.rbs
47
+ homepage: https://github.com/andrew/json-schema-diff
48
+ licenses: []
49
+ metadata:
50
+ homepage_uri: https://github.com/andrew/json-schema-diff
51
+ source_code_uri: https://github.com/andrew/json-schema-diff
52
+ changelog_uri: https://github.com/andrew/json-schema-diff/blob/main/CHANGELOG.md
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 3.2.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.6.9
68
+ specification_version: 4
69
+ summary: Semantic diff for JSON files using JSON Schema metadata
70
+ test_files: []