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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +215 -0
- data/README.md +485 -0
- data/Rakefile +8 -0
- data/SECURITY.md +143 -0
- data/examples/capslock/README.md +27 -0
- data/examples/capslock/capslock-v0.5.0.json +78 -0
- data/examples/capslock/capslock-v0.6.0.json +113 -0
- data/examples/capslock/capslock.schema.json +169 -0
- data/examples/generic/report-v1.2.0.json +63 -0
- data/examples/generic/report-v1.3.0.json +77 -0
- data/examples/generic/security-report.schema.json +149 -0
- data/examples/zizmor/README.md +26 -0
- data/examples/zizmor/zizmor-v0.1.0.json +108 -0
- data/examples/zizmor/zizmor-v0.2.0.json +160 -0
- data/examples/zizmor/zizmor.schema.json +300 -0
- data/exe/json-schema-diff +6 -0
- data/lib/json/schema/diff/cli.rb +101 -0
- data/lib/json/schema/diff/comparer.rb +83 -0
- data/lib/json/schema/diff/formatter.rb +149 -0
- data/lib/json/schema/diff/schema_parser.rb +71 -0
- data/lib/json/schema/diff/version.rb +9 -0
- data/lib/json/schema/diff.rb +17 -0
- data/sig/json/schema/diff.rbs +8 -0
- metadata +70 -0
|
@@ -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,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
|
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: []
|