kettle-drift 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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +2 -0
- data/CHANGELOG.md +49 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +251 -0
- data/FUNDING.md +74 -0
- data/LICENSE.md +12 -0
- data/README.md +477 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/certs/pboling.pem +27 -0
- data/exe/kettle-drift +6 -0
- data/lib/kettle/drift/cli.rb +72 -0
- data/lib/kettle/drift/diff.rb +29 -0
- data/lib/kettle/drift/duplicate_line_validator.rb +238 -0
- data/lib/kettle/drift/lock_file.rb +32 -0
- data/lib/kettle/drift/outcome.rb +49 -0
- data/lib/kettle/drift/plugin.rb +80 -0
- data/lib/kettle/drift/process/calculate_diff.rb +68 -0
- data/lib/kettle/drift/process/printer.rb +63 -0
- data/lib/kettle/drift/process.rb +64 -0
- data/lib/kettle/drift/rakelib/drift.rake +65 -0
- data/lib/kettle/drift/serializer.rb +86 -0
- data/lib/kettle/drift/tasks.rb +10 -0
- data/lib/kettle/drift/version.rb +10 -0
- data/lib/kettle/drift.rb +150 -0
- data/sig/kettle/drift/version.rbs +9 -0
- data/sig/kettle/drift.rbs +6 -0
- data.tar.gz.sig +3 -0
- metadata +302 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Kettle
|
|
7
|
+
module Drift
|
|
8
|
+
class CLI
|
|
9
|
+
def run(argv = ARGV)
|
|
10
|
+
options = parse(argv)
|
|
11
|
+
return options if options.is_a?(Integer)
|
|
12
|
+
|
|
13
|
+
project_root = File.expand_path(options.fetch(:project_root))
|
|
14
|
+
outcome = Kettle::Drift.run(
|
|
15
|
+
project_root: project_root,
|
|
16
|
+
template_dir: options[:template_dir],
|
|
17
|
+
min_chars: options.fetch(:min_chars),
|
|
18
|
+
json_path: options[:json_path],
|
|
19
|
+
lock_path: options[:lock_path],
|
|
20
|
+
mode: options.fetch(:mode),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if outcome.clean?
|
|
24
|
+
puts "[kettle-drift] ✅ No duplicate drift detected (min_chars=#{options[:min_chars]}, files=#{outcome.files.size}, baseline=#{outcome.baseline_set.size})"
|
|
25
|
+
else
|
|
26
|
+
puts "[kettle-drift] ⚠️ #{outcome.warning_count} drift warning(s) across #{outcome.results.size} unique chunk(s) (files=#{outcome.files.size}, baseline=#{outcome.baseline_set.size})"
|
|
27
|
+
puts "[kettle-drift] 📄 Report: #{Kettle::Drift.display_path(outcome.json_path)}" if outcome.json_path
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
outcome.exit_code
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def parse(argv)
|
|
36
|
+
options = {
|
|
37
|
+
min_chars: Kettle::Drift::DuplicateLineValidator::DEFAULT_MIN_CHARS,
|
|
38
|
+
lock_path: Kettle::Drift::DEFAULT_LOCKFILE,
|
|
39
|
+
mode: :update,
|
|
40
|
+
project_root: Dir.pwd,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
parser = OptionParser.new do |opts|
|
|
44
|
+
opts.banner = "Usage: kettle-drift [PROJECT_ROOT] [options]"
|
|
45
|
+
opts.on("--min-chars=N", Integer, "Minimum non-whitespace characters per line") { |value| options[:min_chars] = value }
|
|
46
|
+
opts.on("--json=PATH", "Write JSON report to PATH") { |value| options[:json_path] = value }
|
|
47
|
+
opts.on("--lockfile=PATH", "Use PATH for the lockfile") { |value| options[:lock_path] = value }
|
|
48
|
+
opts.on("--template-dir=PATH", "Use template-managed files and template baseline from PATH") { |value| options[:template_dir] = value }
|
|
49
|
+
opts.on("--update", "Update the lockfile when drift is new, reduced, or otherwise changed (default)") { options[:mode] = :update }
|
|
50
|
+
opts.on("--check", "Fail if current drift differs from the lockfile") { options[:mode] = :check }
|
|
51
|
+
opts.on("--force-update", "Update the lockfile even when drift gets worse") { options[:mode] = :force_update }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
remaining = parser.parse(argv.dup)
|
|
55
|
+
options[:project_root] = remaining.first if remaining.first
|
|
56
|
+
options[:template_dir] = expand_optional_path(options[:template_dir], File.expand_path(options[:project_root]))
|
|
57
|
+
options[:lock_path] = File.expand_path(options.fetch(:lock_path), File.expand_path(options[:project_root]))
|
|
58
|
+
options[:json_path] = options[:json_path] && File.expand_path(options[:json_path], File.expand_path(options[:project_root]))
|
|
59
|
+
options
|
|
60
|
+
rescue OptionParser::ParseError => e
|
|
61
|
+
warn("[kettle-drift] #{e.message}")
|
|
62
|
+
2
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def expand_optional_path(path, project_root)
|
|
66
|
+
return if path.to_s.strip.empty?
|
|
67
|
+
|
|
68
|
+
File.expand_path(path, project_root)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kettle
|
|
4
|
+
module Drift
|
|
5
|
+
class Diff
|
|
6
|
+
attr_reader :state, :new_entries, :fixed_entries, :unchanged_entries
|
|
7
|
+
|
|
8
|
+
def initialize(state:, new_entries: [], fixed_entries: [], unchanged_entries: [])
|
|
9
|
+
@state = state
|
|
10
|
+
@new_entries = new_entries
|
|
11
|
+
@fixed_entries = fixed_entries
|
|
12
|
+
@unchanged_entries = unchanged_entries
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def statistics
|
|
16
|
+
{
|
|
17
|
+
left: unchanged_entries.size + new_entries.size,
|
|
18
|
+
fixed: fixed_entries.size,
|
|
19
|
+
new: new_entries.size,
|
|
20
|
+
unchanged: unchanged_entries.size,
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def files
|
|
25
|
+
new_entries.group_by { |entry| entry[:file] }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module Kettle
|
|
8
|
+
module Drift
|
|
9
|
+
# Scans managed files for repeated adjacent-line chunks that usually signal
|
|
10
|
+
# duplication drift or template corruption.
|
|
11
|
+
module DuplicateLineValidator
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
DEFAULT_MIN_CHARS = 6
|
|
15
|
+
BINARY_EXTENSIONS = Set.new(%w[
|
|
16
|
+
.7z
|
|
17
|
+
.bz2
|
|
18
|
+
.class
|
|
19
|
+
.dll
|
|
20
|
+
.exe
|
|
21
|
+
.gem
|
|
22
|
+
.gif
|
|
23
|
+
.gz
|
|
24
|
+
.ico
|
|
25
|
+
.jar
|
|
26
|
+
.jpeg
|
|
27
|
+
.jpg
|
|
28
|
+
.pdf
|
|
29
|
+
.png
|
|
30
|
+
.so
|
|
31
|
+
.tar
|
|
32
|
+
.tgz
|
|
33
|
+
.ttf
|
|
34
|
+
.woff
|
|
35
|
+
.woff2
|
|
36
|
+
.xz
|
|
37
|
+
.zip
|
|
38
|
+
]).freeze
|
|
39
|
+
APPRAISALS_DEP_LINE_RE = /\A(?:eval_gemfile|gem)\s+["']/
|
|
40
|
+
CHANGELOG_METRIC_RE = /\A-\s+(?:(?:(?:line|branch)\s+)?coverage:|\d+\.\d+%\s+documented)/i
|
|
41
|
+
EXCLUDED_FILENAMES = Set.new(["CODE_OF_CONDUCT.md", ".gitlab-ci.yml"]).freeze
|
|
42
|
+
KETTLE_JEM_CONFIG_RE = /\A(?:strategy|recipe|preference|add_missing|freeze_token|file_type|max_recursion_depth):\s/
|
|
43
|
+
RAKEFILE_ENV_ASSIGNMENT_RE = /\AENV\[["']/
|
|
44
|
+
RESCUE_LOAD_ERROR_RE = /\Arescue\s+LoadError/
|
|
45
|
+
NOCOV_MARKER_RE = /\A# :nocov:\z/
|
|
46
|
+
CHANGELOG_SUBHEADINGS = Set.new([
|
|
47
|
+
"### Added",
|
|
48
|
+
"### Changed",
|
|
49
|
+
"### Deprecated",
|
|
50
|
+
"### Removed",
|
|
51
|
+
"### Fixed",
|
|
52
|
+
"### Security",
|
|
53
|
+
]).freeze
|
|
54
|
+
|
|
55
|
+
def scan(files:, min_chars: DEFAULT_MIN_CHARS)
|
|
56
|
+
duplicates = {}
|
|
57
|
+
|
|
58
|
+
files.each do |path|
|
|
59
|
+
next unless File.file?(path)
|
|
60
|
+
next if EXCLUDED_FILENAMES.include?(File.basename(path.to_s))
|
|
61
|
+
|
|
62
|
+
content = read_text_content(path)
|
|
63
|
+
next unless content
|
|
64
|
+
|
|
65
|
+
fence_lines = (File.extname(path.to_s) == ".md") ? compute_fence_lines(content) : Set.new
|
|
66
|
+
indexed = content.each_line.map.with_index(1) { |raw, n| [n, raw.strip] }
|
|
67
|
+
|
|
68
|
+
chunk_map = Hash.new { |h, k| h[k] = [] }
|
|
69
|
+
indexed.each_cons(2) do |(lineno1, line1), (lineno2, line2)|
|
|
70
|
+
next if line1.gsub(/\s/, "").length <= min_chars
|
|
71
|
+
next if line2.gsub(/\s/, "").length <= min_chars
|
|
72
|
+
next if CHANGELOG_SUBHEADINGS.include?(line1)
|
|
73
|
+
next if fence_lines.include?(lineno1) && fence_lines.include?(lineno2)
|
|
74
|
+
next if ignored_duplicate_chunk?(path, line1, line2)
|
|
75
|
+
|
|
76
|
+
chunk_map["#{line1}\n#{line2}"] << lineno1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
chunk_map.each do |chunk_content, start_lines|
|
|
80
|
+
next if start_lines.size < 2
|
|
81
|
+
|
|
82
|
+
duplicates[chunk_content] ||= []
|
|
83
|
+
duplicates[chunk_content] << {
|
|
84
|
+
file: path,
|
|
85
|
+
lines: start_lines,
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
duplicates
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def ignored_duplicate_chunk?(path, line1, line2)
|
|
94
|
+
basename = File.basename(path.to_s)
|
|
95
|
+
|
|
96
|
+
if basename == "Appraisals"
|
|
97
|
+
return true if APPRAISALS_DEP_LINE_RE.match?(line1) && APPRAISALS_DEP_LINE_RE.match?(line2)
|
|
98
|
+
return true if line1.start_with?("#") && APPRAISALS_DEP_LINE_RE.match?(line2)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
return true if basename == "CHANGELOG.md" && CHANGELOG_METRIC_RE.match?(line1) && CHANGELOG_METRIC_RE.match?(line2)
|
|
102
|
+
return true if basename == "Rakefile" && RAKEFILE_ENV_ASSIGNMENT_RE.match?(line1) && RAKEFILE_ENV_ASSIGNMENT_RE.match?(line2)
|
|
103
|
+
return true if RESCUE_LOAD_ERROR_RE.match?(line1) && NOCOV_MARKER_RE.match?(line2)
|
|
104
|
+
return true if File.extname(path.to_s) == ".md" && line1.start_with?("|") && line2.start_with?("|")
|
|
105
|
+
return true if basename == ".kettle-jem.yml" && KETTLE_JEM_CONFIG_RE.match?(line1) && KETTLE_JEM_CONFIG_RE.match?(line2)
|
|
106
|
+
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def compute_fence_lines(content)
|
|
111
|
+
in_fence = false
|
|
112
|
+
fence_marker = nil
|
|
113
|
+
fence_lines = Set.new
|
|
114
|
+
|
|
115
|
+
content.each_line.with_index(1) do |raw, lineno|
|
|
116
|
+
stripped = raw.strip
|
|
117
|
+
if in_fence
|
|
118
|
+
fence_lines.add(lineno)
|
|
119
|
+
if stripped.match?(/\A#{Regexp.escape(fence_marker)}\s*\z/)
|
|
120
|
+
in_fence = false
|
|
121
|
+
fence_marker = nil
|
|
122
|
+
end
|
|
123
|
+
elsif (match = stripped.match(/\A(`{3,}|~{3,})/))
|
|
124
|
+
fence_marker = match[1]
|
|
125
|
+
in_fence = true
|
|
126
|
+
fence_lines.add(lineno)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
fence_lines
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def scan_template_results(template_results:, min_chars: DEFAULT_MIN_CHARS)
|
|
134
|
+
written_files = template_results.select { |_path, rec| %i[create replace].include?(rec[:action]) }.keys
|
|
135
|
+
scan(files: written_files, min_chars: min_chars)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def baseline(template_dir: nil, min_chars: DEFAULT_MIN_CHARS)
|
|
139
|
+
return Set.new unless template_dir && File.directory?(template_dir)
|
|
140
|
+
|
|
141
|
+
template_files = Dir.glob(
|
|
142
|
+
File.join(template_dir, "**", "*"),
|
|
143
|
+
File::FNM_DOTMATCH,
|
|
144
|
+
).select { |f| File.file?(f) }
|
|
145
|
+
|
|
146
|
+
Set.new(scan(files: template_files, min_chars: min_chars).keys)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def subtract_baseline(results, baseline_set:)
|
|
150
|
+
results.reject { |line_content, _| baseline_set.include?(line_content) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def template_managed_files(project_root:, template_dir: nil)
|
|
154
|
+
template_dir ||= File.join(project_root, "template")
|
|
155
|
+
return [] unless File.directory?(template_dir)
|
|
156
|
+
|
|
157
|
+
managed = []
|
|
158
|
+
Dir.glob(File.join(template_dir, "**", "*"), File::FNM_DOTMATCH).each do |src|
|
|
159
|
+
next unless File.file?(src)
|
|
160
|
+
|
|
161
|
+
rel = src.sub(%r{^#{Regexp.escape(template_dir)}/?}, "")
|
|
162
|
+
rel = rel.sub(/\.example\z/, "")
|
|
163
|
+
next if rel.include?(".no-osc")
|
|
164
|
+
|
|
165
|
+
dest = File.join(project_root, rel)
|
|
166
|
+
managed << dest if File.file?(dest)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
managed.uniq
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def warning_count(results)
|
|
173
|
+
results.values.flatten.size
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def to_json(results)
|
|
177
|
+
JSON.pretty_generate(results.transform_values do |entries|
|
|
178
|
+
entries.map { |entry| {file: Kettle::Drift.display_path(entry[:file]), lines: entry[:lines]} }
|
|
179
|
+
end)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def write_json(results, json_path)
|
|
183
|
+
FileUtils.mkdir_p(File.dirname(json_path))
|
|
184
|
+
File.write(json_path, to_json(results))
|
|
185
|
+
json_path
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def report_summary(results, project_root: nil)
|
|
189
|
+
return "No duplicate lines detected.\n" if results.empty?
|
|
190
|
+
|
|
191
|
+
lines = ["### Duplicate Line Report\n", "| Chunk (line1 ↵ line2) | File | Start Lines |", "|---|---|---|"]
|
|
192
|
+
|
|
193
|
+
results.each do |content, entries|
|
|
194
|
+
display = content.gsub("\n", " ↵ ")
|
|
195
|
+
display = "#{display[0, 77]}..." if display.length > 80
|
|
196
|
+
display = display.gsub("|", "\\|")
|
|
197
|
+
|
|
198
|
+
entries.each do |entry|
|
|
199
|
+
file = Kettle::Drift.display_path(entry[:file])
|
|
200
|
+
if project_root
|
|
201
|
+
display_root = Kettle::Drift.display_path(project_root)
|
|
202
|
+
file = file.sub(%r{^#{Regexp.escape(display_root)}/?}, "")
|
|
203
|
+
end
|
|
204
|
+
lines << "| `#{display}` | #{file} | #{entry[:lines].join(", ")} |"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
lines << ""
|
|
209
|
+
lines.join("\n")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def read_text_content(path)
|
|
213
|
+
raw = File.binread(path)
|
|
214
|
+
return if binary_content?(path, raw)
|
|
215
|
+
|
|
216
|
+
content = raw.dup.force_encoding(Encoding::UTF_8)
|
|
217
|
+
return content if content.valid_encoding?
|
|
218
|
+
|
|
219
|
+
content.scrub
|
|
220
|
+
rescue StandardError
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def binary_content?(path, raw)
|
|
225
|
+
return true if BINARY_EXTENSIONS.include?(File.extname(path.to_s).downcase)
|
|
226
|
+
|
|
227
|
+
sample = raw.byteslice(0, 4096) || "".b
|
|
228
|
+
return true if sample.include?("\x00")
|
|
229
|
+
return false if sample.empty?
|
|
230
|
+
|
|
231
|
+
control_bytes = sample.each_byte.count do |byte|
|
|
232
|
+
(0..8).cover?(byte) || byte == 11 || byte == 12 || (14..31).cover?(byte)
|
|
233
|
+
end
|
|
234
|
+
control_bytes.fdiv(sample.bytesize) > 0.1
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Kettle
|
|
6
|
+
module Drift
|
|
7
|
+
class LockFile
|
|
8
|
+
attr_reader :path
|
|
9
|
+
|
|
10
|
+
def initialize(path)
|
|
11
|
+
@path = path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def read_results
|
|
15
|
+
return unless File.exist?(path)
|
|
16
|
+
|
|
17
|
+
Kettle::Drift::Serializer.deserialize(File.read(path, encoding: Encoding::UTF_8))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write_results(results, project_root: nil)
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
22
|
+
File.write(path, Kettle::Drift::Serializer.serialize(results, project_root: project_root), encoding: Encoding::UTF_8)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delete
|
|
26
|
+
return unless File.exist?(path)
|
|
27
|
+
|
|
28
|
+
File.delete(path)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kettle
|
|
4
|
+
module Drift
|
|
5
|
+
class Outcome
|
|
6
|
+
attr_reader :project_root,
|
|
7
|
+
:files,
|
|
8
|
+
:template_dir,
|
|
9
|
+
:baseline_set,
|
|
10
|
+
:results,
|
|
11
|
+
:warning_count,
|
|
12
|
+
:json_path,
|
|
13
|
+
:lock_path,
|
|
14
|
+
:mode,
|
|
15
|
+
:diff,
|
|
16
|
+
:exit_code
|
|
17
|
+
|
|
18
|
+
def initialize(
|
|
19
|
+
project_root:,
|
|
20
|
+
files:,
|
|
21
|
+
template_dir:,
|
|
22
|
+
baseline_set:,
|
|
23
|
+
results:,
|
|
24
|
+
warning_count:,
|
|
25
|
+
json_path:,
|
|
26
|
+
lock_path:,
|
|
27
|
+
mode:,
|
|
28
|
+
diff:,
|
|
29
|
+
exit_code:
|
|
30
|
+
)
|
|
31
|
+
@project_root = project_root
|
|
32
|
+
@files = files
|
|
33
|
+
@template_dir = template_dir
|
|
34
|
+
@baseline_set = baseline_set
|
|
35
|
+
@results = results
|
|
36
|
+
@warning_count = warning_count
|
|
37
|
+
@json_path = json_path
|
|
38
|
+
@lock_path = lock_path
|
|
39
|
+
@mode = mode
|
|
40
|
+
@diff = diff
|
|
41
|
+
@exit_code = exit_code
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def clean?
|
|
45
|
+
results.empty?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kettle
|
|
4
|
+
module Drift
|
|
5
|
+
module Plugin
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
SNIPPET_MARKER = "### DUPLICATE DRIFT TASKS"
|
|
9
|
+
|
|
10
|
+
RAKEFILE_SNIPPET = <<~RUBY
|
|
11
|
+
### DUPLICATE DRIFT TASKS
|
|
12
|
+
begin
|
|
13
|
+
require "kettle/drift"
|
|
14
|
+
Kettle::Drift.install_tasks
|
|
15
|
+
rescue LoadError
|
|
16
|
+
desc("(stub) kettle:drift:check is unavailable")
|
|
17
|
+
task("kettle:drift:check") do
|
|
18
|
+
warn("NOTE: kettle-drift isn't installed, or is disabled for \#{RUBY_VERSION} in the current environment")
|
|
19
|
+
end
|
|
20
|
+
desc("(stub) kettle:drift:update is unavailable")
|
|
21
|
+
task("kettle:drift:update") do
|
|
22
|
+
warn("NOTE: kettle-drift isn't installed, or is disabled for \#{RUBY_VERSION} in the current environment")
|
|
23
|
+
end
|
|
24
|
+
desc("(stub) kettle:drift:force_update is unavailable")
|
|
25
|
+
task("kettle:drift:force_update") do
|
|
26
|
+
warn("NOTE: kettle-drift isn't installed, or is disabled for \#{RUBY_VERSION} in the current environment")
|
|
27
|
+
end
|
|
28
|
+
desc("(stub) kettle:drift is unavailable")
|
|
29
|
+
task("kettle:drift" => "kettle:drift:update")
|
|
30
|
+
end
|
|
31
|
+
RUBY
|
|
32
|
+
|
|
33
|
+
def register!(registrar)
|
|
34
|
+
registrar.after_phase(:remaining_files) do |context:, **|
|
|
35
|
+
inject_rakefile_tasks(context)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def inject_rakefile_tasks(context)
|
|
40
|
+
rakefile_path = File.join(context.project_root, "Rakefile")
|
|
41
|
+
return unless File.exist?(rakefile_path)
|
|
42
|
+
|
|
43
|
+
existing = File.read(rakefile_path)
|
|
44
|
+
updated = upsert_rakefile_snippet(existing)
|
|
45
|
+
return if updated == existing
|
|
46
|
+
|
|
47
|
+
File.write(rakefile_path, updated)
|
|
48
|
+
context.helpers.record_template_result(rakefile_path, :replace)
|
|
49
|
+
context.out.report_detail("[kettle-drift] Injected Rakefile tasks")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def upsert_rakefile_snippet(content)
|
|
53
|
+
if content.include?(SNIPPET_MARKER)
|
|
54
|
+
return replace_existing_snippet(content)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
insert_snippet(content)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def replace_existing_snippet(content)
|
|
61
|
+
marker_index = content.index(SNIPPET_MARKER)
|
|
62
|
+
next_section_index = content.index(/\n### [A-Z][^\n]*\n/, marker_index + SNIPPET_MARKER.length)
|
|
63
|
+
prefix = content[0...marker_index].rstrip
|
|
64
|
+
suffix = next_section_index ? content[next_section_index..].lstrip : ""
|
|
65
|
+
"#{[prefix, RAKEFILE_SNIPPET.rstrip, suffix].reject(&:empty?).join("\n\n").rstrip}\n"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def insert_snippet(content)
|
|
69
|
+
lines = content.lines
|
|
70
|
+
require_index = lines.rindex { |line| line.match?(/^\s*require\s+["']kettle\/dev["']/) }
|
|
71
|
+
if require_index
|
|
72
|
+
lines.insert(require_index + 1, "\n", RAKEFILE_SNIPPET, "\n")
|
|
73
|
+
lines.join
|
|
74
|
+
else
|
|
75
|
+
[content.rstrip, "", RAKEFILE_SNIPPET.rstrip, ""].join("\n")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kettle
|
|
4
|
+
module Drift
|
|
5
|
+
class Process
|
|
6
|
+
module CalculateDiff
|
|
7
|
+
class << self
|
|
8
|
+
def call(new_results, old_results)
|
|
9
|
+
new_entries = flatten_results(new_results)
|
|
10
|
+
return Kettle::Drift::Diff.new(state: :complete, fixed_entries: [], new_entries: [], unchanged_entries: []) if new_entries.empty? && old_results.nil?
|
|
11
|
+
return Kettle::Drift::Diff.new(state: :new, new_entries: new_entries, unchanged_entries: []) if old_results.nil?
|
|
12
|
+
|
|
13
|
+
old_entries = flatten_results(old_results)
|
|
14
|
+
new_map = index_entries(new_entries)
|
|
15
|
+
old_map = index_entries(old_entries)
|
|
16
|
+
|
|
17
|
+
added = (new_map.keys - old_map.keys).map { |key| new_map.fetch(key) }
|
|
18
|
+
fixed = (old_map.keys - new_map.keys).map { |key| old_map.fetch(key) }
|
|
19
|
+
unchanged = (new_map.keys & old_map.keys).map { |key| new_map.fetch(key) }
|
|
20
|
+
|
|
21
|
+
state = if new_entries.empty?
|
|
22
|
+
:complete
|
|
23
|
+
elsif added.empty? && fixed.empty?
|
|
24
|
+
:no_changes
|
|
25
|
+
elsif added.empty?
|
|
26
|
+
:better
|
|
27
|
+
elsif fixed.empty?
|
|
28
|
+
:worse
|
|
29
|
+
else
|
|
30
|
+
:updated
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Kettle::Drift::Diff.new(
|
|
34
|
+
state: state,
|
|
35
|
+
new_entries: added,
|
|
36
|
+
fixed_entries: fixed,
|
|
37
|
+
unchanged_entries: unchanged,
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def flatten_results(results)
|
|
44
|
+
results.keys.sort.flat_map do |chunk|
|
|
45
|
+
Array(results.fetch(chunk)).map do |entry|
|
|
46
|
+
{
|
|
47
|
+
chunk: chunk,
|
|
48
|
+
file: entry.fetch(:file),
|
|
49
|
+
lines: entry.fetch(:lines),
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def index_entries(entries)
|
|
56
|
+
entries.each_with_object({}) do |entry, indexed|
|
|
57
|
+
indexed[entry_key(entry)] = entry
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def entry_key(entry)
|
|
62
|
+
[entry.fetch(:chunk), entry.fetch(:file), Array(entry.fetch(:lines))]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kettle
|
|
4
|
+
module Drift
|
|
5
|
+
class Process
|
|
6
|
+
class Printer
|
|
7
|
+
def initialize(diff:, lock_path:, mode: :update)
|
|
8
|
+
@diff = diff
|
|
9
|
+
@lock_path = lock_path
|
|
10
|
+
@mode = mode
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def print_results
|
|
14
|
+
send(:"print_#{diff.state}")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :diff, :lock_path, :mode
|
|
20
|
+
|
|
21
|
+
def print_complete
|
|
22
|
+
puts "Kettle Drift is complete!"
|
|
23
|
+
puts "Removing `#{Kettle::Drift.display_path(lock_path)}` lock file..." if diff.statistics[:fixed].positive?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def print_updated
|
|
27
|
+
if mode == :force_update
|
|
28
|
+
puts "Kettle Drift found both fixed drift and new untracked drift, and is force-updating the lockfile."
|
|
29
|
+
else
|
|
30
|
+
puts "Kettle Drift found both fixed drift and new untracked drift."
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def print_no_changes
|
|
35
|
+
puts "Kettle Drift got no changes."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def print_new
|
|
39
|
+
puts "Kettle Drift got results for the first time. #{diff.statistics[:left]} drift item(s) found."
|
|
40
|
+
puts "Don't forget to commit `#{Kettle::Drift.display_path(lock_path)}`."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def print_better
|
|
44
|
+
puts "Kettle Drift got #{diff.statistics[:fixed]} drift item(s) fixed, #{diff.statistics[:left]} left. Keep going!"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def print_worse
|
|
48
|
+
if mode == :force_update
|
|
49
|
+
puts "Kettle Drift found new untracked drift and is force-updating the lockfile:"
|
|
50
|
+
else
|
|
51
|
+
puts "Uh oh, Kettle Drift got worse:"
|
|
52
|
+
end
|
|
53
|
+
diff.files.each do |file, entries|
|
|
54
|
+
puts "-> #{Kettle::Drift.display_path(file)} (#{entries.size} new drift item(s))"
|
|
55
|
+
entries.each do |entry|
|
|
56
|
+
puts " (lines #{entry[:lines].join(", ")}) #{entry[:chunk].inspect}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "process/calculate_diff"
|
|
4
|
+
require_relative "process/printer"
|
|
5
|
+
|
|
6
|
+
module Kettle
|
|
7
|
+
module Drift
|
|
8
|
+
class Process
|
|
9
|
+
Result = Struct.new(:diff, :exit_code, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
attr_reader :project_root, :lock_file, :old_results, :new_results, :mode, :printer_class
|
|
12
|
+
|
|
13
|
+
def initialize(project_root:, results:, lock_path:, mode: :update, printer_class: Kettle::Drift::Process::Printer)
|
|
14
|
+
@project_root = File.expand_path(project_root)
|
|
15
|
+
@lock_file = Kettle::Drift::LockFile.new(lock_path)
|
|
16
|
+
@old_results = lock_file.read_results
|
|
17
|
+
@new_results = Kettle::Drift::Serializer.normalize(results, project_root: @project_root)
|
|
18
|
+
@mode = mode
|
|
19
|
+
@printer_class = printer_class
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
run.exit_code
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
diff = Kettle::Drift::Process::CalculateDiff.call(new_results, old_results)
|
|
28
|
+
printer_class&.new(diff: diff, lock_path: lock_file.path, mode: mode)&.print_results
|
|
29
|
+
|
|
30
|
+
exit_code = error_code(diff)
|
|
31
|
+
sync_lock_file(diff) if exit_code.zero?
|
|
32
|
+
Result.new(diff: diff, exit_code: exit_code)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def fail_with_outdated_lock?(diff)
|
|
38
|
+
return false unless mode == :check
|
|
39
|
+
return false if diff.state == :complete && old_results.nil?
|
|
40
|
+
|
|
41
|
+
diff.state != :no_changes
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def new_untracked_drift?(diff)
|
|
45
|
+
return false if old_results.nil?
|
|
46
|
+
|
|
47
|
+
diff.new_entries.any?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def sync_lock_file(diff)
|
|
51
|
+
return lock_file.delete if diff.state == :complete
|
|
52
|
+
|
|
53
|
+
lock_file.write_results(new_results)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def error_code(diff)
|
|
57
|
+
return 1 if fail_with_outdated_lock?(diff)
|
|
58
|
+
return 1 if new_untracked_drift?(diff) && mode != :force_update
|
|
59
|
+
|
|
60
|
+
0
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|