semverve 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,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+
5
+ require_relative "../semverve"
6
+ require_relative "generator"
7
+ require_relative "semantic_version"
8
+ require_relative "version_file"
9
+ require_relative "version_code_references"
10
+ require_relative "version_metadata"
11
+ require_relative "version_references"
12
+
13
+ module Semverve
14
+ ##
15
+ # Defines Semverve's Rake tasks for the current Rake application.
16
+ class Task
17
+ include Rake::DSL
18
+
19
+ class << self
20
+ ##
21
+ # Installs Semverve tasks once for the current Rake application.
22
+ #
23
+ # @return [Semverve::Task, nil]
24
+ def install
25
+ return if installed_for_current_application?
26
+
27
+ new
28
+ end
29
+
30
+ private
31
+
32
+ ##
33
+ # Whether tasks were already installed for the current Rake application.
34
+ #
35
+ # @return [Boolean]
36
+ def installed_for_current_application?
37
+ installed_applications.include?(Rake.application.object_id)
38
+ end
39
+
40
+ ##
41
+ # Rake application object IDs that already have Semverve tasks.
42
+ #
43
+ # @return [Array<Integer>]
44
+ def installed_applications
45
+ @installed_applications ||= []
46
+ end
47
+
48
+ ##
49
+ # Records the current Rake application as installed.
50
+ #
51
+ # @return [Array<Integer>]
52
+ def mark_current_application_installed
53
+ installed_applications << Rake.application.object_id
54
+ end
55
+ end
56
+
57
+ ##
58
+ # Configures and defines Semverve tasks if needed.
59
+ #
60
+ # @yieldparam [Semverve::Configuration] configuration
61
+ #
62
+ # @return [Semverve::Task]
63
+ def initialize
64
+ yield Semverve.configuration if block_given?
65
+
66
+ unless self.class.send(:installed_for_current_application?)
67
+ define
68
+ self.class.send(:mark_current_application_installed)
69
+ end
70
+ end
71
+
72
+ ##
73
+ # Defines the +semverve:*+ Rake tasks.
74
+ #
75
+ # @return [void]
76
+ def define
77
+ namespace :semverve do
78
+ desc "Print the current version from the version.rb file"
79
+ task :current do
80
+ puts VersionFile.new(Semverve.configuration.resolved).current
81
+ end
82
+
83
+ namespace :increment do
84
+ desc "Increment the version's PATCH level"
85
+ task :patch do
86
+ increment(:patch)
87
+ end
88
+
89
+ desc "Increment the version's MINOR level"
90
+ task :minor do
91
+ increment(:minor)
92
+ end
93
+
94
+ desc "Increment the version's MAJOR level"
95
+ task :major do
96
+ increment(:major)
97
+ end
98
+ end
99
+
100
+ desc "Generate a version.rb file"
101
+ task :generate do
102
+ puts "Generated #{Generator.new(Semverve.configuration.resolved).generate}"
103
+ end
104
+
105
+ desc "Set the version.rb file to VERSION"
106
+ task :set do
107
+ set
108
+ end
109
+
110
+ desc "Check version references, code literals, and metadata"
111
+ task :check do
112
+ check
113
+ end
114
+
115
+ namespace :check do
116
+ desc "Check configured files for stale version references"
117
+ task :references do
118
+ check_references
119
+ end
120
+
121
+ desc "Check configured code files for version literals"
122
+ task :code do
123
+ check_code
124
+ end
125
+
126
+ desc "Check gem metadata for version mismatches"
127
+ task :metadata do
128
+ check_metadata
129
+ end
130
+ end
131
+
132
+ desc "Fix version references, code literals, and metadata"
133
+ task :fix do
134
+ fix
135
+ end
136
+
137
+ namespace :fix do
138
+ desc "Replace stale version references in configured files"
139
+ task :references do
140
+ fix_references
141
+ end
142
+
143
+ desc "Replace safe code version literals in configured files"
144
+ task :code do
145
+ fix_code
146
+ end
147
+
148
+ desc "Fix safe gem metadata version mismatches"
149
+ task :metadata do
150
+ fix_metadata
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ ##
159
+ # Increments a version level and reports the update.
160
+ #
161
+ # @param [Symbol] level
162
+ #
163
+ # @return [void]
164
+ def increment(level)
165
+ configuration = Semverve.configuration.resolved
166
+ update = VersionFile.new(configuration).increment(level)
167
+
168
+ report(update, configuration)
169
+ end
170
+
171
+ ##
172
+ # Sets the version to the value from the +VERSION+ environment variable.
173
+ #
174
+ # @return [void]
175
+ def set
176
+ configuration = Semverve.configuration.resolved
177
+ requested_version = SemanticVersion.parse(
178
+ ENV.fetch("VERSION") { raise Error, "Set VERSION=MAJOR.MINOR.PATCH." }
179
+ )
180
+ update = VersionFile.new(configuration).set(requested_version)
181
+
182
+ report(update, configuration)
183
+ end
184
+
185
+ ##
186
+ # Checks all version-maintenance surfaces.
187
+ #
188
+ # @return [void]
189
+ def check
190
+ configuration, current_version = check_context
191
+ report_findings(
192
+ check_groups(configuration, current_version),
193
+ current_version,
194
+ clean_message: "Version checks passed."
195
+ )
196
+ end
197
+
198
+ ##
199
+ # Fixes all check surfaces.
200
+ #
201
+ # @return [void]
202
+ def fix
203
+ configuration, current_version = check_context
204
+ report_fix_results(fix_results(configuration, current_version))
205
+ end
206
+
207
+ ##
208
+ # Checks configured files for stale version references.
209
+ #
210
+ # @return [void]
211
+ def check_references
212
+ configuration, current_version = check_context
213
+ report_findings(
214
+ [["version reference", VersionReferences.new(configuration, current_version).findings]],
215
+ current_version,
216
+ clean_message: "Version references are current."
217
+ )
218
+ end
219
+
220
+ ##
221
+ # Replaces stale version references in configured files.
222
+ #
223
+ # @return [void]
224
+ def fix_references
225
+ configuration, current_version = check_context
226
+ report_fix_results(
227
+ [["version reference", VersionReferences.new(configuration, current_version).fix]],
228
+ clean_message: "Version references are current."
229
+ )
230
+ end
231
+
232
+ ##
233
+ # Checks configured code files for version literals.
234
+ #
235
+ # @return [void]
236
+ def check_code
237
+ configuration, current_version = check_context
238
+ report_findings(
239
+ [["code version literal", VersionCodeReferences.new(configuration, current_version).findings]],
240
+ current_version,
241
+ clean_message: "Code version literals are current."
242
+ )
243
+ end
244
+
245
+ ##
246
+ # Replaces safe code version literals in configured files.
247
+ #
248
+ # @return [void]
249
+ def fix_code
250
+ configuration, current_version = check_context
251
+ report_fix_results(
252
+ [["code version literal", VersionCodeReferences.new(configuration, current_version).fix]],
253
+ clean_message: "Code version literals are current."
254
+ )
255
+ end
256
+
257
+ ##
258
+ # Checks gem metadata for version mismatches.
259
+ #
260
+ # @return [void]
261
+ def check_metadata
262
+ configuration, current_version = check_context
263
+ report_findings(
264
+ [[nil, VersionMetadata.new(configuration, current_version).findings]],
265
+ current_version,
266
+ clean_message: "Version metadata is current."
267
+ )
268
+ end
269
+
270
+ ##
271
+ # Fixes safe gem metadata version mismatches.
272
+ #
273
+ # @return [void]
274
+ def fix_metadata
275
+ configuration, current_version = check_context
276
+ report_fix_results(
277
+ [["metadata version", VersionMetadata.new(configuration, current_version).fix]],
278
+ clean_message: "Version metadata is current."
279
+ )
280
+ end
281
+
282
+ ##
283
+ # Resolved configuration and current version for check tasks.
284
+ #
285
+ # @return [Array(Semverve::ResolvedConfiguration, Semverve::SemanticVersion)]
286
+ def check_context
287
+ configuration = Semverve.configuration.resolved
288
+ [configuration, VersionFile.new(configuration).current]
289
+ end
290
+
291
+ ##
292
+ # Finding groups enabled for the umbrella check task.
293
+ #
294
+ # @param [Semverve::ResolvedConfiguration] configuration
295
+ # @param [Semverve::SemanticVersion] current_version
296
+ #
297
+ # @return [Array<Array(String, Array)>]
298
+ def check_groups(configuration, current_version)
299
+ groups = []
300
+ if configuration.version_checks.include?(:doc_references)
301
+ groups << ["version reference", VersionReferences.new(configuration, current_version).findings]
302
+ end
303
+ if configuration.version_checks.include?(:code_references)
304
+ groups << ["code version literal", VersionCodeReferences.new(configuration, current_version).findings]
305
+ end
306
+ if configuration.version_checks.include?(:metadata)
307
+ groups << [nil, VersionMetadata.new(configuration, current_version).findings]
308
+ end
309
+ groups
310
+ end
311
+
312
+ ##
313
+ # Fix results enabled for the umbrella fix task.
314
+ #
315
+ # @param [Semverve::ResolvedConfiguration] configuration
316
+ # @param [Semverve::SemanticVersion] current_version
317
+ #
318
+ # @return [Array<Array(String, #replacement_count, #changed_files)>]
319
+ def fix_results(configuration, current_version)
320
+ results = []
321
+ if configuration.version_checks.include?(:doc_references)
322
+ results << ["version reference", VersionReferences.new(configuration, current_version).fix]
323
+ end
324
+ if configuration.version_checks.include?(:code_references)
325
+ results << ["code version literal", VersionCodeReferences.new(configuration, current_version).fix]
326
+ end
327
+ if configuration.version_checks.include?(:metadata)
328
+ results << ["metadata version", VersionMetadata.new(configuration, current_version).fix]
329
+ end
330
+ results
331
+ end
332
+
333
+ ##
334
+ # Prints findings and raises when mismatches exist.
335
+ #
336
+ # @param [Array<Array(String, Array)>] groups
337
+ # @param [Semverve::SemanticVersion] current_version
338
+ # @param [String] clean_message
339
+ #
340
+ # @return [void]
341
+ def report_findings(groups, current_version, clean_message:)
342
+ findings = groups.flat_map do |(label, group_findings)|
343
+ group_findings.map { |finding| [label || finding.label, finding] }
344
+ end
345
+
346
+ if findings.empty?
347
+ puts clean_message
348
+ return
349
+ end
350
+
351
+ findings.each do |(label, finding)|
352
+ puts "#{finding.path}:#{finding.line}:#{finding.column}: #{label} #{finding.version} -> #{current_version}"
353
+ end
354
+
355
+ issue = findings.one? ? "issue" : "issues"
356
+ raise Error, "Found #{findings.count} version check #{issue}."
357
+ end
358
+
359
+ ##
360
+ # Prints fix results.
361
+ #
362
+ # @param [Array<Array(String, #replacement_count, #changed_files)>] results
363
+ # @param [String] clean_message
364
+ #
365
+ # @return [void]
366
+ def report_fix_results(results, clean_message: "Version checks passed.")
367
+ replacement_count = results.sum { |(_label, result)| result.replacement_count }
368
+ bundle_lock_ran = results.any? { |(_label, result)| result.respond_to?(:bundle_lock_ran) && result.bundle_lock_ran }
369
+
370
+ if replacement_count.zero? && !bundle_lock_ran
371
+ puts clean_message
372
+ return
373
+ end
374
+
375
+ results.each do |(label, result)|
376
+ result.changed_files.each { |path| puts "Updated #{path}" }
377
+ next if result.replacement_count.zero?
378
+
379
+ noun = (result.replacement_count == 1) ? label : "#{label}s"
380
+ puts "Replaced #{result.replacement_count} #{noun}."
381
+ end
382
+
383
+ puts "Ran bundle lock." if bundle_lock_ran
384
+ end
385
+
386
+ ##
387
+ # Reports an update and runs configured follow-up commands.
388
+ #
389
+ # @param [Semverve::VersionFile::UpdateResult] update
390
+ # @param [Semverve::ResolvedConfiguration] configuration
391
+ #
392
+ # @return [void]
393
+ def report(update, configuration)
394
+ unless update.changed?
395
+ puts "Version is already #{update.version}"
396
+ return
397
+ end
398
+
399
+ if update.version < update.previous_version
400
+ warn "Warning: updating to version #{update.version}, which is lower than the current version #{update.previous_version}."
401
+ end
402
+
403
+ puts "Updating to version #{update.version} (was #{update.previous_version})"
404
+ configuration.command_runner.call("bundle lock") if configuration.bundle_lock
405
+ end
406
+ end
407
+ end
408
+
409
+ Semverve::Task.install
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Semverve
4
+ ##
5
+ # Module that contains all gem version information. Follows semantic
6
+ # versioning. Read: https://semver.org/
7
+ module Version
8
+ ##
9
+ # Major version.
10
+ #
11
+ # @return [Integer]
12
+ MAJOR = 0
13
+
14
+ ##
15
+ # Minor version.
16
+ #
17
+ # @return [Integer]
18
+ MINOR = 1
19
+
20
+ ##
21
+ # Patch version.
22
+ #
23
+ # @return [Integer]
24
+ PATCH = 0
25
+
26
+ module_function
27
+
28
+ ##
29
+ # Version as +[MAJOR, MINOR, PATCH]+
30
+ #
31
+ # @return [Array<Integer>]
32
+ def to_a
33
+ [MAJOR, MINOR, PATCH]
34
+ end
35
+
36
+ ##
37
+ # Version as +MAJOR.MINOR.PATCH+
38
+ #
39
+ # @return [String]
40
+ def to_s
41
+ to_a.join(".")
42
+ end
43
+ end
44
+
45
+ ##
46
+ # The version, as a string.
47
+ #
48
+ # @return [String]
49
+ VERSION = Version.to_s
50
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "file_list_resolver"
5
+ require_relative "semantic_version"
6
+
7
+ module Semverve
8
+ ##
9
+ # Finds and updates safe version literals in configured code files.
10
+ class VersionCodeReferences
11
+ ##
12
+ # Ruby assignments that are safe enough to rewrite automatically.
13
+ #
14
+ # @return [Regexp]
15
+ RUBY_ASSIGNMENT_PATTERN = Configuration::DEFAULT_VERSION_CODE_REFERENCE_PATTERN
16
+
17
+ ##
18
+ # A code version literal found in a configured file.
19
+ class Finding
20
+ ##
21
+ # Path relative to the configured project root.
22
+ #
23
+ # @return [String]
24
+ attr_reader :path
25
+
26
+ ##
27
+ # One-based line number.
28
+ #
29
+ # @return [Integer]
30
+ attr_reader :line
31
+
32
+ ##
33
+ # One-based column number.
34
+ #
35
+ # @return [Integer]
36
+ attr_reader :column
37
+
38
+ ##
39
+ # Referenced semantic version.
40
+ #
41
+ # @return [Semverve::SemanticVersion]
42
+ attr_reader :version
43
+
44
+ ##
45
+ # Initializes a finding.
46
+ #
47
+ # @param [String] path
48
+ # @param [Integer] line
49
+ # @param [Integer] column
50
+ # @param [Semverve::SemanticVersion] version
51
+ #
52
+ # @return [Semverve::VersionCodeReferences::Finding]
53
+ def initialize(path:, line:, column:, version:)
54
+ @path = path
55
+ @line = line
56
+ @column = column
57
+ @version = version
58
+ end
59
+ end
60
+
61
+ ##
62
+ # Result of fixing code version literals.
63
+ class FixResult
64
+ ##
65
+ # Files changed by the fix.
66
+ #
67
+ # @return [Array<String>]
68
+ attr_reader :changed_files
69
+
70
+ ##
71
+ # Number of replacements made.
72
+ #
73
+ # @return [Integer]
74
+ attr_reader :replacement_count
75
+
76
+ ##
77
+ # Initializes a fix result.
78
+ #
79
+ # @param [Array<String>] changed_files
80
+ # @param [Integer] replacement_count
81
+ #
82
+ # @return [Semverve::VersionCodeReferences::FixResult]
83
+ def initialize(changed_files:, replacement_count:)
84
+ @changed_files = changed_files
85
+ @replacement_count = replacement_count
86
+ end
87
+ end
88
+
89
+ ##
90
+ # Initializes code version literal scanning.
91
+ #
92
+ # @param [Semverve::ResolvedConfiguration] configuration
93
+ # @param [Semverve::SemanticVersion] current_version
94
+ #
95
+ # @return [Semverve::VersionCodeReferences]
96
+ def initialize(configuration, current_version)
97
+ @configuration = configuration
98
+ @current_version = current_version
99
+ end
100
+
101
+ ##
102
+ # Code version literal findings in configured files.
103
+ #
104
+ # @return [Array<Semverve::VersionCodeReferences::Finding>]
105
+ def findings
106
+ files.flat_map { |path| findings_for_file(path) }
107
+ end
108
+
109
+ ##
110
+ # Replaces found code version literals with the current version.
111
+ #
112
+ # @return [Semverve::VersionCodeReferences::FixResult]
113
+ def fix
114
+ changed_files = []
115
+ replacement_count = 0
116
+
117
+ files.each do |path|
118
+ content = File.read(path)
119
+ fixed, count = fixed_content(content)
120
+
121
+ next if count.zero?
122
+
123
+ File.write(path, fixed)
124
+ changed_files << relative_path(path)
125
+ replacement_count += count
126
+ end
127
+
128
+ FixResult.new(changed_files: changed_files, replacement_count: replacement_count)
129
+ end
130
+
131
+ private
132
+
133
+ ##
134
+ # Resolved Semverve configuration.
135
+ #
136
+ # @return [Semverve::ResolvedConfiguration]
137
+ attr_reader :configuration
138
+
139
+ ##
140
+ # Current gem version.
141
+ #
142
+ # @return [Semverve::SemanticVersion]
143
+ attr_reader :current_version
144
+
145
+ ##
146
+ # Absolute configured project root.
147
+ #
148
+ # @return [String]
149
+ def root
150
+ configuration.root
151
+ end
152
+
153
+ ##
154
+ # Configured pattern used to find code version literals.
155
+ #
156
+ # @return [Regexp]
157
+ def pattern
158
+ configuration.version_code_reference_pattern
159
+ end
160
+
161
+ ##
162
+ # Absolute files to scan.
163
+ #
164
+ # @return [Array<String>]
165
+ def files
166
+ @files ||= FileListResolver.new(
167
+ root: root,
168
+ file_list: configuration.version_code_reference_files
169
+ ).files.reject { |path| File.expand_path(path) == File.expand_path(configuration.absolute_version_file) }
170
+ end
171
+
172
+ ##
173
+ # Findings for a single file.
174
+ #
175
+ # @param [String] path
176
+ #
177
+ # @return [Array<Semverve::VersionCodeReferences::Finding>]
178
+ def findings_for_file(path)
179
+ File.readlines(path).filter_map.with_index(1) do |line, line_number|
180
+ match = line.match(pattern)
181
+ next unless match
182
+
183
+ version = SemanticVersion.parse(match[:version])
184
+ next if version == current_version
185
+
186
+ Finding.new(
187
+ path: relative_path(path),
188
+ line: line_number,
189
+ column: match.begin(:version) + 1,
190
+ version: version
191
+ )
192
+ end
193
+ end
194
+
195
+ ##
196
+ # Fixed content and replacement count.
197
+ #
198
+ # @param [String] content
199
+ #
200
+ # @return [Array(String, Integer)]
201
+ def fixed_content(content)
202
+ replacement_count = 0
203
+
204
+ fixed = content.lines.map do |line|
205
+ match = line.match(pattern)
206
+ next line unless match
207
+
208
+ version = SemanticVersion.parse(match[:version])
209
+ next line if version == current_version
210
+
211
+ replacement_count += 1
212
+ replace_matched_version(line)
213
+ end.join
214
+
215
+ [fixed, replacement_count]
216
+ end
217
+
218
+ ##
219
+ # Replaces only the named version capture in the first pattern match.
220
+ #
221
+ # @param [String] line
222
+ #
223
+ # @return [String]
224
+ def replace_matched_version(line)
225
+ line.sub(pattern) do |matched_text|
226
+ match = Regexp.last_match
227
+ version_start = match.begin(:version) - match.begin(0)
228
+ version_end = match.end(:version) - match.begin(0)
229
+
230
+ "#{matched_text[0...version_start]}#{current_version}#{matched_text[version_end..]}"
231
+ end
232
+ end
233
+
234
+ ##
235
+ # Path relative to the project root.
236
+ #
237
+ # @param [String] path
238
+ #
239
+ # @return [String]
240
+ def relative_path(path)
241
+ path.delete_prefix("#{root}/")
242
+ end
243
+ end
244
+ end