ivar 0.2.0 → 0.4.6
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/.augment-guidelines +5 -3
- data/.devcontainer/devcontainer.json +28 -20
- data/.devcontainer/post-create.sh +18 -0
- data/.editorconfig +35 -0
- data/.rubocop.yml +6 -0
- data/.standard.yml +1 -1
- data/.vscode/extensions.json +3 -1
- data/.vscode/launch.json +25 -0
- data/.vscode/settings.json +38 -2
- data/CHANGELOG.md +99 -1
- data/README.md +272 -207
- data/Rakefile +1 -1
- data/VERSION.md +46 -0
- data/examples/check_all_block_example.rb +84 -0
- data/examples/check_all_example.rb +42 -0
- data/examples/inheritance_with_kwarg_init.rb +156 -0
- data/examples/inheritance_with_positional_init.rb +142 -0
- data/examples/mixed_positional_and_kwarg_init.rb +125 -0
- data/examples/require_check_all_example.rb +23 -0
- data/examples/sandwich_inheritance.rb +1 -1
- data/examples/sandwich_with_accessors.rb +78 -0
- data/examples/sandwich_with_block_values.rb +54 -0
- data/examples/sandwich_with_checked.rb +0 -1
- data/examples/sandwich_with_checked_once.rb +0 -1
- data/examples/sandwich_with_initial_values.rb +52 -0
- data/examples/sandwich_with_ivar_block.rb +6 -9
- data/examples/sandwich_with_ivar_macro.rb +4 -4
- data/examples/sandwich_with_kwarg_init.rb +78 -0
- data/examples/sandwich_with_positional_init.rb +50 -0
- data/examples/sandwich_with_shared_values.rb +54 -0
- data/hooks/README.md +42 -0
- data/hooks/install.sh +12 -0
- data/hooks/pre-commit +54 -0
- data/lib/ivar/check_all.rb +7 -0
- data/lib/ivar/check_all_manager.rb +72 -0
- data/lib/ivar/check_policy.rb +29 -0
- data/lib/ivar/checked/class_methods.rb +19 -0
- data/lib/ivar/checked/instance_methods.rb +35 -0
- data/lib/ivar/checked.rb +17 -24
- data/lib/ivar/declaration.rb +30 -0
- data/lib/ivar/explicit_declaration.rb +56 -0
- data/lib/ivar/explicit_keyword_declaration.rb +24 -0
- data/lib/ivar/explicit_positional_declaration.rb +19 -0
- data/lib/ivar/macros.rb +48 -111
- data/lib/ivar/manifest.rb +124 -0
- data/lib/ivar/policies.rb +13 -1
- data/lib/ivar/project_root.rb +59 -0
- data/lib/ivar/targeted_prism_analysis.rb +144 -0
- data/lib/ivar/validation.rb +6 -29
- data/lib/ivar/version.rb +1 -1
- data/lib/ivar.rb +141 -9
- data/script/console +11 -0
- data/script/de-lint +2 -0
- data/script/de-lint-unsafe +2 -0
- data/script/lint +2 -0
- data/script/release +213 -0
- data/script/setup +8 -0
- data/script/test +2 -0
- metadata +46 -8
- data/examples/sandwich_with_kwarg.rb +0 -45
- data/ivar.gemspec +0 -49
- data/lib/ivar/auto_check.rb +0 -77
- data/lib/ivar/prism_analysis.rb +0 -102
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "prism"
|
4
|
+
|
5
|
+
module Ivar
|
6
|
+
# Analyzes a class to find instance variable references in specific instance methods
|
7
|
+
# Unlike PrismAnalysis, this targets only the class's own methods (not inherited)
|
8
|
+
# and precisely locates instance variable references within each method definition
|
9
|
+
class TargetedPrismAnalysis
|
10
|
+
attr_reader :ivars, :references
|
11
|
+
|
12
|
+
def initialize(klass)
|
13
|
+
@klass = klass
|
14
|
+
@references = []
|
15
|
+
@method_locations = {}
|
16
|
+
collect_method_locations
|
17
|
+
analyze_methods
|
18
|
+
@ivars = unique_ivar_names
|
19
|
+
end
|
20
|
+
|
21
|
+
# Returns a list of hashes each representing a code reference to an ivar
|
22
|
+
# Each hash includes var name, path, line number, and column number
|
23
|
+
def ivar_references
|
24
|
+
@references
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def unique_ivar_names
|
30
|
+
@references.map { |ref| ref[:name] }.uniq.sort
|
31
|
+
end
|
32
|
+
|
33
|
+
def collect_method_locations
|
34
|
+
# Get all instance methods defined directly on this class (not inherited)
|
35
|
+
instance_methods = @klass.instance_methods(false) | @klass.private_instance_methods(false)
|
36
|
+
instance_methods.each do |method_name|
|
37
|
+
# Try to get the method from the stash first, then fall back to the current method
|
38
|
+
method_obj = Ivar.get_stashed_method(@klass, method_name) || @klass.instance_method(method_name)
|
39
|
+
next unless method_obj.source_location
|
40
|
+
|
41
|
+
file_path, line_number = method_obj.source_location
|
42
|
+
@method_locations[method_name] = {path: file_path, line: line_number}
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def analyze_methods
|
47
|
+
# Group methods by file to avoid parsing the same file multiple times
|
48
|
+
methods_by_file = @method_locations.group_by { |_, location| location[:path] }
|
49
|
+
|
50
|
+
methods_by_file.each do |file_path, methods_in_file|
|
51
|
+
code = File.read(file_path)
|
52
|
+
result = Prism.parse(code)
|
53
|
+
|
54
|
+
methods_in_file.each do |method_name, location|
|
55
|
+
visitor = MethodTargetedInstanceVariableReferenceVisitor.new(
|
56
|
+
file_path,
|
57
|
+
method_name,
|
58
|
+
location[:line]
|
59
|
+
)
|
60
|
+
|
61
|
+
result.value.accept(visitor)
|
62
|
+
@references.concat(visitor.references)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Visitor that collects instance variable references within a specific method definition
|
69
|
+
class MethodTargetedInstanceVariableReferenceVisitor < Prism::Visitor
|
70
|
+
attr_reader :references
|
71
|
+
|
72
|
+
def initialize(file_path, target_method_name, target_line)
|
73
|
+
super()
|
74
|
+
@file_path = file_path
|
75
|
+
@target_method_name = target_method_name
|
76
|
+
@target_line = target_line
|
77
|
+
@references = []
|
78
|
+
@in_target_method = false
|
79
|
+
end
|
80
|
+
|
81
|
+
# Only visit the method definition we're targeting
|
82
|
+
def visit_def_node(node)
|
83
|
+
# Check if this is our target method
|
84
|
+
if node.name.to_sym == @target_method_name && node.location.start_line == @target_line
|
85
|
+
# Found our target method, now collect all instance variable references within it
|
86
|
+
collector = IvarCollector.new(@file_path, @target_method_name)
|
87
|
+
node.body&.accept(collector)
|
88
|
+
@references = collector.references
|
89
|
+
false
|
90
|
+
else
|
91
|
+
# Sometimes methods are found inside other methods...
|
92
|
+
node.body&.accept(self)
|
93
|
+
true
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Helper visitor that collects all instance variable references
|
99
|
+
class IvarCollector < Prism::Visitor
|
100
|
+
attr_reader :references
|
101
|
+
|
102
|
+
def initialize(file_path, method_name)
|
103
|
+
super()
|
104
|
+
@file_path = file_path
|
105
|
+
@method_name = method_name
|
106
|
+
@references = []
|
107
|
+
end
|
108
|
+
|
109
|
+
def visit_instance_variable_read_node(node)
|
110
|
+
add_reference(node)
|
111
|
+
true
|
112
|
+
end
|
113
|
+
|
114
|
+
def visit_instance_variable_write_node(node)
|
115
|
+
add_reference(node)
|
116
|
+
true
|
117
|
+
end
|
118
|
+
|
119
|
+
def visit_instance_variable_operator_write_node(node)
|
120
|
+
add_reference(node)
|
121
|
+
true
|
122
|
+
end
|
123
|
+
|
124
|
+
def visit_instance_variable_target_node(node)
|
125
|
+
add_reference(node)
|
126
|
+
true
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
def add_reference(node)
|
132
|
+
location = node.location
|
133
|
+
reference = {
|
134
|
+
name: node.name.to_sym,
|
135
|
+
path: @file_path,
|
136
|
+
line: location.start_line,
|
137
|
+
column: location.start_column,
|
138
|
+
method: @method_name
|
139
|
+
}
|
140
|
+
|
141
|
+
@references << reference
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
data/lib/ivar/validation.rb
CHANGED
@@ -9,46 +9,23 @@ module Ivar
|
|
9
9
|
# @param add [Array<Symbol>] Additional instance variables to allow
|
10
10
|
# @param policy [Symbol, Policy] The policy to use for handling unknown variables
|
11
11
|
def check_ivars(add: [], policy: nil)
|
12
|
-
# Get the policy to use
|
13
12
|
policy ||= get_check_policy
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
defined_ivars = instance_variables.map(&:to_sym)
|
21
|
-
|
22
|
-
# Add any additional allowed variables
|
23
|
-
allowed_ivars = defined_ivars + add
|
24
|
-
|
25
|
-
# Get all instance variable references from the analysis
|
26
|
-
# This includes location information for each reference
|
27
|
-
references = analysis.ivar_references
|
28
|
-
|
29
|
-
# Find references to unknown variables (those not in allowed_ivars)
|
30
|
-
unknown_refs = references.reject { |ref| allowed_ivars.include?(ref[:name]) }
|
31
|
-
|
32
|
-
# Handle unknown variables according to the policy
|
13
|
+
analyses = Ivar.get_ancestral_analyses(self.class)
|
14
|
+
manifest = Ivar.get_or_create_manifest(self.class)
|
15
|
+
declared_ivars = manifest.all_declarations.map(&:name)
|
16
|
+
allowed_ivars = (Ivar.known_internal_ivars | instance_variables | declared_ivars | add).uniq
|
17
|
+
instance_refs = analyses.flat_map(&:references)
|
18
|
+
unknown_refs = instance_refs.reject { |ref| allowed_ivars.include?(ref[:name]) }
|
33
19
|
policy_instance = Ivar.get_policy(policy)
|
34
20
|
policy_instance.handle_unknown_ivars(unknown_refs, self.class, allowed_ivars)
|
35
21
|
end
|
36
22
|
|
37
|
-
# For backward compatibility - delegates to check_ivars with warn_once policy
|
38
|
-
# @param add [Array<Symbol>] Additional instance variables to allow
|
39
|
-
def check_ivars_once(add: [])
|
40
|
-
check_ivars(add: add, policy: :warn_once)
|
41
|
-
end
|
42
|
-
|
43
23
|
private
|
44
24
|
|
45
25
|
# Get the check policy for this instance
|
46
26
|
# @return [Symbol, Policy] The check policy
|
47
27
|
def get_check_policy
|
48
|
-
# If the class has an ivar_check_policy method, use that
|
49
28
|
return self.class.ivar_check_policy if self.class.respond_to?(:ivar_check_policy)
|
50
|
-
|
51
|
-
# Otherwise, use the global default
|
52
29
|
Ivar.check_policy
|
53
30
|
end
|
54
31
|
end
|
data/lib/ivar/version.rb
CHANGED
data/lib/ivar.rb
CHANGED
@@ -1,42 +1,124 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "ivar/version"
|
4
|
-
require_relative "ivar/prism_analysis"
|
5
4
|
require_relative "ivar/policies"
|
6
5
|
require_relative "ivar/validation"
|
7
6
|
require_relative "ivar/macros"
|
8
|
-
require_relative "ivar/
|
7
|
+
require_relative "ivar/project_root"
|
8
|
+
require_relative "ivar/check_all_manager"
|
9
|
+
require_relative "ivar/check_policy"
|
10
|
+
require_relative "ivar/checked"
|
11
|
+
require_relative "ivar/manifest"
|
12
|
+
require_relative "ivar/targeted_prism_analysis"
|
9
13
|
require "prism"
|
10
14
|
require "did_you_mean"
|
15
|
+
require "pathname"
|
11
16
|
|
12
17
|
module Ivar
|
13
18
|
@analysis_cache = {}
|
14
19
|
@checked_classes = {}
|
15
|
-
@default_check_policy = :
|
20
|
+
@default_check_policy = :warn_once
|
21
|
+
@manifest_registry = {}
|
22
|
+
@project_root = nil
|
23
|
+
MUTEX = Mutex.new
|
24
|
+
PROJECT_ROOT_FINDER = ProjectRoot.new
|
25
|
+
CHECK_ALL_MANAGER = CheckAllManager.new
|
26
|
+
|
27
|
+
# Pattern for internal instance variables
|
28
|
+
INTERNAL_IVAR_PREFIX = "@__ivar_"
|
29
|
+
|
30
|
+
# Checks if an instance variable name is an internal variable
|
31
|
+
# @param ivar_name [Symbol, String] The instance variable name to check
|
32
|
+
# @return [Boolean] Whether the variable is an internal variable
|
33
|
+
def self.internal_ivar?(ivar_name)
|
34
|
+
ivar_name.to_s.start_with?(INTERNAL_IVAR_PREFIX)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Returns a list of known internal instance variables
|
38
|
+
# @return [Array<Symbol>] List of known internal instance variables
|
39
|
+
def self.known_internal_ivars
|
40
|
+
[
|
41
|
+
:@__ivar_check_policy,
|
42
|
+
:@__ivar_initialized_vars,
|
43
|
+
:@__ivar_method_impl_stash,
|
44
|
+
:@__ivar_skip_init
|
45
|
+
]
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.get_ancestral_analyses(klass)
|
49
|
+
klass
|
50
|
+
.ancestors.filter_map { |ancestor| maybe_get_analysis(ancestor) }
|
51
|
+
.reverse
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.maybe_get_analysis(klass)
|
55
|
+
if klass.include?(Validation)
|
56
|
+
get_analysis(klass)
|
57
|
+
end
|
58
|
+
end
|
16
59
|
|
17
60
|
# Returns a cached analysis for the given class or module
|
18
61
|
# Creates a new analysis if one doesn't exist in the cache
|
62
|
+
# Thread-safe: Multiple readers are allowed, but writers block all other access
|
19
63
|
def self.get_analysis(klass)
|
20
|
-
@analysis_cache[klass]
|
64
|
+
return @analysis_cache[klass] if @analysis_cache.key?(klass)
|
65
|
+
|
66
|
+
MUTEX.synchronize do
|
67
|
+
@analysis_cache[klass] ||= TargetedPrismAnalysis.new(klass)
|
68
|
+
end
|
21
69
|
end
|
22
70
|
|
23
71
|
# Checks if a class has been validated already
|
24
72
|
# @param klass [Class] The class to check
|
25
73
|
# @return [Boolean] Whether the class has been validated
|
74
|
+
# Thread-safe: Read-only operation
|
26
75
|
def self.class_checked?(klass)
|
27
|
-
@checked_classes.key?(klass)
|
76
|
+
MUTEX.synchronize { @checked_classes.key?(klass) }
|
28
77
|
end
|
29
78
|
|
30
79
|
# Marks a class as having been checked
|
31
80
|
# @param klass [Class] The class to mark as checked
|
81
|
+
# Thread-safe: Write operation protected by mutex
|
32
82
|
def self.mark_class_checked(klass)
|
33
|
-
@checked_classes[klass] = true
|
83
|
+
MUTEX.synchronize { @checked_classes[klass] = true }
|
34
84
|
end
|
35
85
|
|
36
86
|
# For testing purposes - allows clearing the cache
|
87
|
+
# Thread-safe: Write operation protected by mutex
|
37
88
|
def self.clear_analysis_cache
|
38
|
-
|
39
|
-
|
89
|
+
MUTEX.synchronize do
|
90
|
+
@analysis_cache.clear
|
91
|
+
@checked_classes.clear
|
92
|
+
@manifest_registry.clear
|
93
|
+
end
|
94
|
+
PROJECT_ROOT_FINDER.clear_cache
|
95
|
+
end
|
96
|
+
|
97
|
+
# Get or create a manifest for a class or module
|
98
|
+
# @param klass [Class, Module] The class or module to get a manifest for
|
99
|
+
# @param create [Boolean] Whether to create a new manifest if one doesn't exist
|
100
|
+
# @return [Manifest, nil] The manifest for the class or module, or nil if not found and create_if_missing is false
|
101
|
+
def self.get_manifest(klass, create: true)
|
102
|
+
return @manifest_registry[klass] if @manifest_registry.key?(klass)
|
103
|
+
return nil unless create
|
104
|
+
|
105
|
+
MUTEX.synchronize do
|
106
|
+
@manifest_registry[klass] ||= Manifest.new(klass)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# Alias for get_manifest that makes it clearer that it may create a manifest
|
111
|
+
# @param klass [Class, Module] The class or module to get a manifest for
|
112
|
+
# @return [Manifest] The manifest for the class or module
|
113
|
+
def self.get_or_create_manifest(klass)
|
114
|
+
get_manifest(klass, create: true)
|
115
|
+
end
|
116
|
+
|
117
|
+
# Check if a manifest exists for a class or module
|
118
|
+
# @param klass [Class, Module] The class or module to check
|
119
|
+
# @return [Boolean] Whether a manifest exists for the class or module
|
120
|
+
def self.manifest_exists?(klass)
|
121
|
+
@manifest_registry.key?(klass)
|
40
122
|
end
|
41
123
|
|
42
124
|
# Get the default check policy
|
@@ -48,6 +130,56 @@ module Ivar
|
|
48
130
|
# Set the default check policy
|
49
131
|
# @param policy [Symbol, Policy] The default check policy
|
50
132
|
def self.check_policy=(policy)
|
51
|
-
@default_check_policy = policy
|
133
|
+
MUTEX.synchronize { @default_check_policy = policy }
|
134
|
+
end
|
135
|
+
|
136
|
+
def self.project_root=(explicit_root)
|
137
|
+
@project_root = explicit_root
|
138
|
+
end
|
139
|
+
|
140
|
+
# Determines the project root directory based on the caller's location
|
141
|
+
# Delegates to ProjectRoot class
|
142
|
+
# @param caller_location [String, nil] Optional file path to start from (defaults to caller's location)
|
143
|
+
# @return [String] The absolute path to the project root directory
|
144
|
+
def self.project_root(caller_location = nil)
|
145
|
+
@project_root ||= PROJECT_ROOT_FINDER.find(caller_location)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Enables automatic inclusion of Ivar::Checked in all classes and modules
|
149
|
+
# defined within the project root.
|
150
|
+
#
|
151
|
+
# @param block [Proc] Optional block. If provided, auto-checking is only active
|
152
|
+
# for the duration of the block. Otherwise, it remains active indefinitely.
|
153
|
+
# @return [void]
|
154
|
+
def self.check_all(&block)
|
155
|
+
root = project_root
|
156
|
+
CHECK_ALL_MANAGER.enable(root, &block)
|
157
|
+
end
|
158
|
+
|
159
|
+
# Disables automatic inclusion of Ivar::Checked in classes and modules.
|
160
|
+
# @return [void]
|
161
|
+
def self.disable_check_all
|
162
|
+
CHECK_ALL_MANAGER.disable
|
163
|
+
end
|
164
|
+
|
165
|
+
# Gets a method from the stash or returns nil if not found
|
166
|
+
# @param klass [Class] The class that owns the method
|
167
|
+
# @param method_name [Symbol] The name of the method to retrieve
|
168
|
+
# @return [UnboundMethod, nil] The stashed method or nil if not found
|
169
|
+
def self.get_stashed_method(klass, method_name)
|
170
|
+
(klass.instance_variable_get(:@__ivar_method_impl_stash) || {})[method_name]
|
171
|
+
end
|
172
|
+
|
173
|
+
# Stashes a method implementation for a class
|
174
|
+
# @param klass [Class] The class that owns the method
|
175
|
+
# @param method_name [Symbol] The name of the method to stash
|
176
|
+
# @return [UnboundMethod, nil] The stashed method or nil if the method doesn't exist
|
177
|
+
def self.stash_method(klass, method_name)
|
178
|
+
return nil unless klass.method_defined?(method_name) || klass.private_method_defined?(method_name)
|
179
|
+
|
180
|
+
method_impl = klass.instance_method(method_name)
|
181
|
+
stash = klass.instance_variable_get(:@__ivar_method_impl_stash) ||
|
182
|
+
klass.instance_variable_set(:@__ivar_method_impl_stash, {})
|
183
|
+
stash[method_name] = method_impl
|
52
184
|
end
|
53
185
|
end
|
data/script/console
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "ivar"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
require "irb"
|
11
|
+
IRB.start(__FILE__)
|
data/script/de-lint
ADDED
data/script/lint
ADDED
data/script/release
ADDED
@@ -0,0 +1,213 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# This script helps with releasing a new version of the gem
|
5
|
+
# Usage: script/release [major|minor|patch] [options]
|
6
|
+
#
|
7
|
+
# Options:
|
8
|
+
# --yes, -y Skip confirmation prompt
|
9
|
+
# --no-push Skip pushing changes to remote repository
|
10
|
+
|
11
|
+
require "bundler/gem_tasks"
|
12
|
+
require_relative "../lib/ivar/version"
|
13
|
+
|
14
|
+
def error(message)
|
15
|
+
puts "\e[31mError: #{message}\e[0m"
|
16
|
+
exit 1
|
17
|
+
end
|
18
|
+
|
19
|
+
def success(message)
|
20
|
+
puts "\e[32m#{message}\e[0m"
|
21
|
+
end
|
22
|
+
|
23
|
+
def info(message)
|
24
|
+
puts "\e[34m#{message}\e[0m"
|
25
|
+
end
|
26
|
+
|
27
|
+
def get_new_version(current_version, bump_type)
|
28
|
+
major, minor, patch = current_version.split(".").map(&:to_i)
|
29
|
+
|
30
|
+
case bump_type
|
31
|
+
when "major"
|
32
|
+
"#{major + 1}.0.0"
|
33
|
+
when "minor"
|
34
|
+
"#{major}.#{minor + 1}.0"
|
35
|
+
when "patch"
|
36
|
+
"#{major}.#{minor}.#{patch + 1}"
|
37
|
+
else
|
38
|
+
error "Invalid bump type. Use 'major', 'minor', or 'patch'."
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def update_version_file(new_version)
|
43
|
+
version_file_path = "lib/ivar/version.rb"
|
44
|
+
version_content = File.read(version_file_path)
|
45
|
+
updated_content = version_content.gsub(/VERSION = "[0-9]+\.[0-9]+\.[0-9]+"/, "VERSION = \"#{new_version}\"")
|
46
|
+
File.write(version_file_path, updated_content)
|
47
|
+
end
|
48
|
+
|
49
|
+
def update_changelog(new_version)
|
50
|
+
changelog_path = "CHANGELOG.md"
|
51
|
+
changelog_content = File.read(changelog_path)
|
52
|
+
|
53
|
+
# Check if there are unreleased changes
|
54
|
+
unless changelog_content.include?("## [Unreleased]")
|
55
|
+
error "No unreleased changes found in CHANGELOG.md. Add changes before releasing."
|
56
|
+
end
|
57
|
+
|
58
|
+
# Update the changelog with the new version
|
59
|
+
today = Time.now.strftime("%Y-%m-%d")
|
60
|
+
updated_content = changelog_content.gsub(
|
61
|
+
"## [Unreleased]",
|
62
|
+
"## [Unreleased]\n\n## [#{new_version}] - #{today}"
|
63
|
+
)
|
64
|
+
|
65
|
+
File.write(changelog_path, updated_content)
|
66
|
+
end
|
67
|
+
|
68
|
+
def run_tests
|
69
|
+
info "Running tests..."
|
70
|
+
system("bundle exec rake test") || error("Tests failed. Fix the tests before releasing.")
|
71
|
+
end
|
72
|
+
|
73
|
+
def run_linter
|
74
|
+
info "Running linter..."
|
75
|
+
system("bundle exec rake standard") || error("Linter found issues. Fix them before releasing.")
|
76
|
+
end
|
77
|
+
|
78
|
+
def clean_build_artifacts
|
79
|
+
info "Cleaning build artifacts..."
|
80
|
+
system("bundle exec rake clean clobber")
|
81
|
+
# Also remove any stray .gem files in the project root
|
82
|
+
Dir.glob("*.gem").each do |gem_file|
|
83
|
+
info "Removing stray gem file: #{gem_file}"
|
84
|
+
File.delete(gem_file)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def check_for_uncommitted_changes
|
89
|
+
info "Checking for uncommitted changes..."
|
90
|
+
uncommitted_changes = `git status --porcelain`.strip
|
91
|
+
|
92
|
+
if uncommitted_changes.empty?
|
93
|
+
info "No uncommitted changes detected."
|
94
|
+
false
|
95
|
+
else
|
96
|
+
info "Uncommitted changes detected:"
|
97
|
+
puts uncommitted_changes
|
98
|
+
true
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def commit_remaining_changes(new_version)
|
103
|
+
info "Committing remaining changes after release process..."
|
104
|
+
system("git add --all")
|
105
|
+
system("git commit -m \"Post-release cleanup for v#{new_version}\"")
|
106
|
+
info "Remaining changes committed."
|
107
|
+
end
|
108
|
+
|
109
|
+
def push_changes_and_tag(new_version)
|
110
|
+
# Check for any uncommitted changes before pushing
|
111
|
+
has_uncommitted_changes = check_for_uncommitted_changes
|
112
|
+
|
113
|
+
# If there are uncommitted changes, commit them
|
114
|
+
if has_uncommitted_changes
|
115
|
+
commit_remaining_changes(new_version)
|
116
|
+
end
|
117
|
+
|
118
|
+
info "Pushing changes to remote repository..."
|
119
|
+
system("git push origin main") || error("Failed to push changes to remote repository.")
|
120
|
+
|
121
|
+
info "Pushing tag v#{new_version} to remote repository..."
|
122
|
+
system("git push origin v#{new_version}") || error("Failed to push tag to remote repository.")
|
123
|
+
|
124
|
+
success "Changes and tag pushed successfully!"
|
125
|
+
end
|
126
|
+
|
127
|
+
def update_gemfile_lock
|
128
|
+
info "Updating Gemfile.lock with new version..."
|
129
|
+
system("bundle install") || error("Failed to update Gemfile.lock. Run 'bundle install' manually and try again.")
|
130
|
+
info "Gemfile.lock updated successfully."
|
131
|
+
end
|
132
|
+
|
133
|
+
def commit_and_tag(new_version, skip_push = false)
|
134
|
+
info "Committing version bump..."
|
135
|
+
|
136
|
+
# Add all relevant files to staging
|
137
|
+
system("git add lib/ivar/version.rb CHANGELOG.md Gemfile.lock")
|
138
|
+
|
139
|
+
# Commit the changes
|
140
|
+
system("git commit -m \"Bump version to #{new_version}\"")
|
141
|
+
|
142
|
+
info "Creating tag v#{new_version}..."
|
143
|
+
system("git tag -a v#{new_version} -m \"Version #{new_version}\"")
|
144
|
+
|
145
|
+
if skip_push
|
146
|
+
info "Skipping push to remote repository."
|
147
|
+
info "To push the new version manually, run:"
|
148
|
+
puts " git push origin main && git push origin v#{new_version}"
|
149
|
+
else
|
150
|
+
push_changes_and_tag(new_version)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Main script
|
155
|
+
error "Please specify a version bump type: major, minor, or patch" if ARGV.empty?
|
156
|
+
|
157
|
+
# Parse arguments
|
158
|
+
args = ARGV.dup
|
159
|
+
skip_confirmation = args.delete("--yes") || args.delete("-y")
|
160
|
+
skip_push = args.delete("--no-push")
|
161
|
+
bump_type = args[0].downcase if args[0]
|
162
|
+
|
163
|
+
error "Please specify a version bump type: major, minor, or patch" unless bump_type
|
164
|
+
|
165
|
+
current_version = Ivar::VERSION
|
166
|
+
new_version = get_new_version(current_version, bump_type)
|
167
|
+
|
168
|
+
info "Current version: #{current_version}"
|
169
|
+
info "New version: #{new_version}"
|
170
|
+
|
171
|
+
# Skip confirmation if --yes/-y option is provided
|
172
|
+
confirmation = "y" if skip_confirmation
|
173
|
+
|
174
|
+
unless confirmation
|
175
|
+
puts "Continue? (y/n)"
|
176
|
+
confirmation = $stdin.gets.chomp.downcase
|
177
|
+
end
|
178
|
+
|
179
|
+
if confirmation == "y"
|
180
|
+
clean_build_artifacts
|
181
|
+
run_tests
|
182
|
+
run_linter
|
183
|
+
update_version_file(new_version)
|
184
|
+
update_changelog(new_version)
|
185
|
+
update_gemfile_lock
|
186
|
+
commit_and_tag(new_version, skip_push)
|
187
|
+
success "Version bumped to #{new_version}!"
|
188
|
+
|
189
|
+
if skip_push
|
190
|
+
success "Remember to push changes manually to trigger the release workflow."
|
191
|
+
else
|
192
|
+
success "Release workflow triggered!"
|
193
|
+
|
194
|
+
# Final check for any remaining uncommitted changes
|
195
|
+
if check_for_uncommitted_changes
|
196
|
+
info "There are still uncommitted changes after the release process."
|
197
|
+
puts "Would you like to commit and push these changes? (y/n)"
|
198
|
+
cleanup_confirmation = $stdin.gets.chomp.downcase
|
199
|
+
|
200
|
+
if cleanup_confirmation == "y"
|
201
|
+
commit_remaining_changes(new_version)
|
202
|
+
system("git push origin main") || error("Failed to push cleanup changes.")
|
203
|
+
success "Post-release cleanup completed and pushed successfully!"
|
204
|
+
else
|
205
|
+
info "Uncommitted changes left in working directory."
|
206
|
+
end
|
207
|
+
else
|
208
|
+
success "Working directory is clean. Release completed successfully!"
|
209
|
+
end
|
210
|
+
end
|
211
|
+
else
|
212
|
+
info "Release cancelled."
|
213
|
+
end
|
data/script/setup
ADDED
data/script/test
ADDED