kettle-dev 2.0.0 → 2.0.1

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.
@@ -6,11 +6,15 @@ module Kettle
6
6
  module Dev
7
7
  # Unified gemspec reader using RubyGems loader instead of regex parsing.
8
8
  # Returns a Hash with all data used by this project from gemspecs.
9
- # Cache within the process to avoid repeated loads.
9
+ # Results are memoized per project root within the process.
10
10
  class GemSpecReader
11
11
  # Default minimum Ruby version to assume when a gemspec doesn't specify one.
12
12
  # @return [Gem::Version]
13
13
  DEFAULT_MINIMUM_RUBY = Gem::Version.new("1.8").freeze
14
+ CacheState = Struct.new(:entries, :mutex)
15
+ CACHE = CacheState.new({}, Mutex.new)
16
+ private_constant :CacheState, :CACHE
17
+
14
18
  class << self
15
19
  # Load gemspec data for the project at root using RubyGems.
16
20
  # The reader is lenient: failures to load or missing fields are handled with defaults and warnings.
@@ -39,6 +43,11 @@ module Kettle
39
43
  # @option return [String] :bindir
40
44
  # @option return [Array<String>] :executables
41
45
  def load(root)
46
+ cache_key = File.expand_path(root.to_s)
47
+ CACHE.mutex.synchronize do
48
+ return CACHE.entries[cache_key] if CACHE.entries.key?(cache_key)
49
+ end
50
+
42
51
  gemspec_path = Dir.glob(File.join(root.to_s, "*.gemspec")).first
43
52
  spec = nil
44
53
  if gemspec_path && File.file?(gemspec_path)
@@ -50,10 +59,21 @@ module Kettle
50
59
  end
51
60
  end
52
61
 
62
+ gemspec_source = if gemspec_path && File.file?(gemspec_path)
63
+ begin
64
+ File.read(gemspec_path)
65
+ rescue StandardError => e
66
+ Kettle::Dev.debug_error(e, __method__)
67
+ ""
68
+ end
69
+ else
70
+ ""
71
+ end
72
+
53
73
  gem_name = spec&.name.to_s
54
74
  if gem_name.nil? || gem_name.strip.empty?
55
75
  # Be lenient here for tasks that can proceed without gem_name (e.g., choosing destination filenames).
56
- Kernel.warn("kettle-dev: Could not derive gem name. Ensure a valid <name> is set in the gemspec.\n - Tip: set the gem name in your .gemspec file (spec.name).\n - Path searched: #{gemspec_path || "(none found)"}")
76
+ Kernel.warn("kettle-dev: Could not derive gem name. Ensure a valid <name> is set in the gemspec.\n - Tip: set the gem name in your .gemspec file (spec.name).\n - Path searched: #{Kettle::Dev.display_path(gemspec_path || "(none found)")}")
57
77
  gem_name = ""
58
78
  end
59
79
  # minimum ruby version: derived from spec.required_ruby_version
@@ -80,9 +100,11 @@ module Kettle
80
100
 
81
101
  homepage_val = spec&.homepage.to_s
82
102
 
103
+ explicit_forge_org = env_org_override("FORGE_ORG")
104
+
83
105
  # Derive org/repo from homepage or git remote
84
106
  forge_info = derive_forge_and_origin_repo(homepage_val)
85
- forge_org = forge_info[:forge_org]
107
+ forge_org = explicit_forge_org || forge_info[:forge_org]
86
108
  gh_repo = forge_info[:origin_repo]
87
109
  if forge_org.to_s.empty?
88
110
  Kernel.warn("kettle-dev: Could not determine forge org from spec.homepage or git remote.\n - Ensure gemspec.homepage is set to a GitHub URL or that the git remote 'origin' points to GitHub.\n - Example homepage: https://github.com/<org>/<repo>\n - Proceeding with default org: kettle-rb.")
@@ -92,9 +114,14 @@ module Kettle
92
114
  camel = lambda do |s|
93
115
  s.to_s.split(/[_-]/).map { |p| p.gsub(/\b([a-z])/) { Regexp.last_match(1).upcase } }.join
94
116
  end
95
- namespace = gem_name.to_s.split("-").map { |seg| camel.call(seg) }.join("::")
117
+ entrypoint_require = derive_entrypoint_require(
118
+ root: root,
119
+ gem_name: gem_name,
120
+ gemspec_source: gemspec_source,
121
+ )
122
+ namespace_source = entrypoint_require.to_s.empty? ? gem_name.to_s.tr("-", "/") : entrypoint_require.to_s
123
+ namespace = namespace_source.split("/").reject(&:empty?).map { |seg| camel.call(seg) }.join("::")
96
124
  namespace_shield = namespace.gsub("::", "%3A%3A")
97
- entrypoint_require = gem_name.to_s.tr("-", "/")
98
125
  gem_shield = gem_name.to_s.gsub("-", "--").gsub("_", "__")
99
126
 
100
127
  # Funding org (Open Collective handle) detection.
@@ -109,8 +136,9 @@ module Kettle
109
136
  funding_org = nil
110
137
  else
111
138
  env_funding = ENV["FUNDING_ORG"]
112
- if env_funding && !env_funding.to_s.strip.empty?
113
- # FUNDING_ORG is set and non-empty; use it as-is (already filtered by opencollective_disabled?)
139
+ if env_funding && !env_funding.to_s.strip.empty? && !env_funding.match?(/\{KJ\|[^}]+}/)
140
+ # FUNDING_ORG is set, non-empty, and is not an unresolved token placeholder;
141
+ # use it as-is (already filtered by opencollective_disabled?)
114
142
  funding_org = env_funding.to_s
115
143
  else
116
144
  # Preflight: if a YAML exists under the provided root, attempt to read it here so
@@ -131,9 +159,10 @@ module Kettle
131
159
  raise Kettle::Dev::Error, "Unable to determine funding org: #{error.message}"
132
160
  end
133
161
 
134
- {
162
+ result = {
135
163
  gemspec_path: gemspec_path,
136
164
  gem_name: gem_name,
165
+ version: spec&.version.to_s,
137
166
  min_ruby: min_ruby, # Gem::Version instance
138
167
  homepage: homepage_val.to_s,
139
168
  gh_org: forge_org, # Might allow divergence from forge_org someday
@@ -155,6 +184,15 @@ module Kettle
155
184
  bindir: (spec&.bindir || "").to_s,
156
185
  executables: Array(spec&.executables),
157
186
  }
187
+
188
+ CACHE.mutex.synchronize do
189
+ CACHE.entries[cache_key] = result
190
+ end
191
+ result
192
+ end
193
+
194
+ def clear_cache!
195
+ CACHE.mutex.synchronize { CACHE.entries.clear }
158
196
  end
159
197
 
160
198
  private
@@ -193,6 +231,58 @@ module Kettle
193
231
 
194
232
  forge_info
195
233
  end
234
+
235
+ def env_org_override(env_key)
236
+ value = ENV[env_key].to_s.strip
237
+ return if value.empty?
238
+ return if value.match?(Kettle::Dev::ENV_FALSE_RE)
239
+ return if value.match?(/\{KJ\|[^}]+}/)
240
+
241
+ value
242
+ end
243
+
244
+ def derive_entrypoint_require(root:, gem_name:, gemspec_source:)
245
+ from_source = extract_entrypoint_require_from_gemspec_source(gemspec_source)
246
+ return from_source unless from_source.to_s.empty?
247
+
248
+ default_entrypoint = gem_name.to_s.tr("-", "/")
249
+ return default_entrypoint if default_entrypoint_path_exists?(root, default_entrypoint)
250
+
251
+ unique_version_entrypoint(root) || default_entrypoint
252
+ end
253
+
254
+ def extract_entrypoint_require_from_gemspec_source(source)
255
+ content = source.to_s
256
+ patterns = [
257
+ %r{require_relative\s+["']lib/([^"']+)/version["']},
258
+ %r{Kernel\.load\(\s*["'][#][{]__dir__[}]/lib/([^"']+)/version\.rb["']},
259
+ ]
260
+
261
+ patterns.each do |pattern|
262
+ match = content.match(pattern)
263
+ return match[1] if match && !match[1].to_s.strip.empty?
264
+ end
265
+
266
+ nil
267
+ end
268
+
269
+ def default_entrypoint_path_exists?(root, entrypoint_require)
270
+ return false if entrypoint_require.to_s.strip.empty?
271
+
272
+ main_file = File.join(root.to_s, "lib", "#{entrypoint_require}.rb")
273
+ version_file = File.join(root.to_s, "lib", entrypoint_require, "version.rb")
274
+ File.file?(main_file) || File.file?(version_file)
275
+ end
276
+
277
+ def unique_version_entrypoint(root)
278
+ lib_root = File.join(root.to_s, "lib")
279
+ return unless Dir.exist?(lib_root)
280
+
281
+ version_files = Dir.glob(File.join(lib_root, "**", "version.rb")).reject { |path| path.include?("/vendor/") }
282
+ return unless version_files.size == 1
283
+
284
+ version_files.first.sub(%r{\A#{Regexp.escape(lib_root)}/?}, "").sub(%r{/version\.rb\z}, "")
285
+ end
196
286
  end
197
287
  end
198
288
  end
@@ -67,7 +67,7 @@ module Kettle
67
67
  @git = ::Git.open(Dir.pwd)
68
68
  end
69
69
  rescue LoadError => e
70
- Kettle::Dev.debug_error(e, __method__)
70
+ Kettle::Dev.debug_error(e, __method__, backtrace: false)
71
71
  # Optional dependency: fall back to CLI
72
72
  @backend = :cli
73
73
  rescue StandardError => e
@@ -147,6 +147,42 @@ module Kettle
147
147
  nil
148
148
  end
149
149
 
150
+ # Return the list of files currently tracked by git.
151
+ #
152
+ # @return [Array<String>] relative paths of tracked files, empty on error
153
+ def ls_files
154
+ if @backend == :gem
155
+ begin
156
+ @git.ls_files.keys
157
+ rescue StandardError => e
158
+ Kettle::Dev.debug_error(e, __method__)
159
+ []
160
+ end
161
+ else
162
+ out, status = Open3.capture2("git", "ls-files")
163
+ status.success? ? out.split(/\r?\n/).reject(&:empty?) : []
164
+ end
165
+ rescue StandardError => e
166
+ Kettle::Dev.debug_error(e, __method__)
167
+ []
168
+ end
169
+
170
+ # Return the raw `git blame --porcelain` output for a single tracked file.
171
+ #
172
+ # Both backends shell out directly because the `git` gem does not provide
173
+ # a stable porcelain-blame interface. Callers that need only the output
174
+ # string (e.g. +CopyrightCollector+) should stub this method in specs.
175
+ #
176
+ # @param path [String] path to the file, relative to the repository root
177
+ # @return [String] raw porcelain blame output, or "" on error / untracked file
178
+ def blame_porcelain(path)
179
+ out, status = Open3.capture2("git", "blame", "--porcelain", path.to_s)
180
+ status.success? ? out : ""
181
+ rescue StandardError => e
182
+ Kettle::Dev.debug_error(e, __method__)
183
+ ""
184
+ end
185
+
150
186
  # @return [Array<String>] list of remote names
151
187
  def remotes
152
188
  if @backend == :gem
@@ -40,21 +40,21 @@ module Kettle
40
40
  # @return [String, nil] the handle, or nil when not required and not discoverable
41
41
  def handle(required: false, root: nil, strict: false)
42
42
  env = ENV["OPENCOLLECTIVE_HANDLE"]
43
- return env unless env.nil? || env.to_s.strip.empty?
43
+ return env unless env.nil? || env.to_s.strip.empty? || env.match?(/\{KJ\|[^}]+}/)
44
44
 
45
45
  ypath = yaml_path(root)
46
46
  if strict
47
47
  yml = YAML.safe_load(File.read(ypath))
48
48
  if yml.is_a?(Hash)
49
49
  handle = yml["collective"] || yml[:collective] || yml["org"] || yml[:org]
50
- return handle.to_s unless handle.nil? || handle.to_s.strip.empty?
50
+ return handle.to_s unless handle.nil? || handle.to_s.strip.empty? || handle.to_s.match?(/\{KJ\|[^}]+}/)
51
51
  end
52
52
  elsif File.file?(ypath)
53
53
  begin
54
54
  yml = YAML.safe_load(File.read(ypath))
55
55
  if yml.is_a?(Hash)
56
56
  handle = yml["collective"] || yml[:collective] || yml["org"] || yml[:org]
57
- return handle.to_s unless handle.nil? || handle.to_s.strip.empty?
57
+ return handle.to_s unless handle.nil? || handle.to_s.strip.empty? || handle.to_s.match?(/\{KJ\|[^}]+}/)
58
58
  end
59
59
  rescue StandardError => e
60
60
  Kettle::Dev.debug_error(e, __method__) if Kettle::Dev.respond_to?(:debug_error)
@@ -133,7 +133,7 @@ module Kettle
133
133
  begin
134
134
  extract_image_urls_from_text(File.read(f))
135
135
  rescue StandardError => e
136
- warn("[kettle-pre-release] Could not read #{f}: #{e.class}: #{e.message}")
136
+ warn("[kettle-pre-release] Could not read #{Kettle::Dev.display_path(f)}: #{e.class}: #{e.message}")
137
137
  []
138
138
  end
139
139
  end
@@ -179,7 +179,7 @@ module Kettle
179
179
  begin
180
180
  original = File.read(file)
181
181
  rescue StandardError => e
182
- warn("[kettle-pre-release] Could not read #{file}: #{e.class}: #{e.message}")
182
+ warn("[kettle-pre-release] Could not read #{Kettle::Dev.display_path(file)}: #{e.class}: #{e.message}")
183
183
  next
184
184
  end
185
185
 
@@ -199,7 +199,7 @@ module Kettle
199
199
  # Replace exact occurrences of the URL in the markdown content
200
200
  updated.gsub!(url_str, normalized)
201
201
  modified = true
202
- puts " -> #{file}: normalized #{url_str} -> #{normalized}"
202
+ puts " -> #{Kettle::Dev.display_path(file)}: normalized #{url_str} -> #{normalized}"
203
203
  end
204
204
 
205
205
  if modified && updated != original
@@ -207,7 +207,7 @@ module Kettle
207
207
  File.write(file, updated)
208
208
  changed << file
209
209
  rescue StandardError => e
210
- warn("[kettle-pre-release] Could not write #{file}: #{e.class}: #{e.message}")
210
+ warn("[kettle-pre-release] Could not write #{Kettle::Dev.display_path(file)}: #{e.class}: #{e.message}")
211
211
  end
212
212
  end
213
213
  end
@@ -12,18 +12,21 @@ begin
12
12
 
13
13
  # Store current Reek output into REEK file
14
14
  require "open3"
15
+ require "rbconfig"
15
16
  desc("Run reek and store the output into the REEK file")
16
17
  task("reek:update") do
17
- # Run via Bundler if available to ensure the right gem version is used
18
- cmd = %w[bundle exec reek]
18
+ # Resolve the gem executable directly. `bundle exec reek` may prefer a
19
+ # project-local bin/reek binstub, and stale binstubs are a common failure
20
+ # mode during template/bootstrap work.
21
+ cmd = [RbConfig.ruby, Gem.bin_path("reek", "reek")]
19
22
 
20
23
  output, status = Open3.capture2e(*cmd)
21
24
 
22
- File.write("REEK", output)
25
+ normalized_output = output.to_s.strip.empty? ? "" : output
26
+ File.write("REEK", normalized_output)
23
27
 
24
- # Mirror the failure semantics of the standard reek task
25
- unless status.success?
26
- abort("reek:update failed (reek reported smells). Output written to REEK")
28
+ unless status.success? || status.exitstatus == 1
29
+ raise("reek:update failed (reek executable failed with exit #{status.exitstatus}). Output written to REEK")
27
30
  end
28
31
  end
29
32
  Kettle::Dev.register_default("reek:update") unless Kettle::Dev::IS_CI
@@ -7,41 +7,41 @@ require "fileutils"
7
7
  begin
8
8
  require "rake/testtask"
9
9
 
10
- Rake::TestTask.new(:test) do |t|
11
- t.libs << "test"
12
- t.test_files = FileList["test/**/*test*.rb"]
13
- t.verbose = true
10
+ unless Rake::Task.task_defined?(:test)
11
+ Rake::TestTask.new(:test) do |t|
12
+ t.libs << "test"
13
+ t.test_files = FileList["test/**/*test*.rb"]
14
+ t.verbose = true
15
+ end
16
+ # The test task is invoked by the coverage task, so of the two, (i.e., when outside CI),
17
+ # only coverage should be registered as default.
18
+ Kettle::Dev.register_default("test") unless Kettle::Dev.default_registered?("coverage")
14
19
  end
15
- # The test task is invoked by the coverage task, so of the two, (i.e., when outside CI),
16
- # only coverage should be registered as default.
17
- Kettle::Dev.register_default("test") unless Kettle::Dev.default_registered?("coverage")
18
20
  rescue LoadError
19
21
  warn("[kettle-dev][spec_test.rake] failed to load rake/testtask") if Kettle::Dev::DEBUGGING
20
- desc("test task stub")
21
- task(:test) do
22
- warn("NOTE: minitest isn't installed, or is disabled for #{RUBY_VERSION} in the current environment")
22
+ unless Rake::Task.task_defined?(:test)
23
+ desc("test task stub")
24
+ task(:test) do
25
+ warn("NOTE: minitest isn't installed, or is disabled for #{RUBY_VERSION} in the current environment")
26
+ end
23
27
  end
24
28
  end
25
29
 
26
30
  setup_spec_task = ->(default:) {
27
- begin
28
- require "rspec/core/rake_task"
29
-
30
- RSpec::Core::RakeTask.new(:spec)
31
- if default
32
- # This takes the place of the `coverage` task if/when it isn't already registered.
33
- # This is because spec and coverage run the same tests
34
- # (via the coverage task invoking the test task which invokes the spec task),
35
- # so we can't have both in the default task.
36
- Kettle::Dev.register_default("spec") unless Kettle::Dev.default_registered?("coverage")
37
- end
38
- rescue LoadError
39
- warn("[kettle-dev][spec_test.rake] failed to load rspec/core/rake_task") if Kettle::Dev::DEBUGGING
40
- desc("spec task stub")
31
+ unless Rake::Task.task_defined?(:spec)
32
+ desc("Run RSpec code examples")
41
33
  task(:spec) do
42
- warn("NOTE: rspec isn't installed, or is disabled for #{RUBY_VERSION} in the current environment")
34
+ sh("bundle", "exec", "kettle-test")
43
35
  end
44
36
  end
37
+
38
+ if default
39
+ # This takes the place of the `coverage` task if/when it isn't already registered.
40
+ # This is because spec and coverage run the same tests
41
+ # (via the coverage task invoking the test task which invokes the spec task),
42
+ # so we can't have both in the default task.
43
+ Kettle::Dev.register_default("spec") unless Kettle::Dev.default_registered?("coverage")
44
+ end
45
45
  }
46
46
 
47
47
  # Setup RSpec
@@ -4,41 +4,26 @@
4
4
  begin
5
5
  require "yard"
6
6
 
7
- # Load yard-fence rake task if available (provides yard:fence:prepare)
8
- # NOTE: yard-fence >= 0.9 auto-registers its rake task when Rake is available,
9
- # so this explicit require may be redundant. We keep it for backward compatibility
10
- # with older yard-fence versions that don't auto-register.
11
- # The yard:fence:prepare task handles:
12
- # - Cleaning docs/ directory (if YARD_FENCE_CLEAN_DOCS=true)
13
- # - Preparing tmp/yard-fence/ with sanitized markdown files
7
+ YARD::Rake::YardocTask.new(:yard) do |t|
8
+ # Keep .yardopts as the canonical source for included files, plugins,
9
+ # readme selection, and output directory. Diverging task-local file lists
10
+ # caused `rake yard` and `yard` to generate different docs sites.
11
+ t.files = []
12
+ end
13
+
14
14
  begin
15
- require "yard/fence/rake_task"
16
- # Only create if not already defined (yard-fence may have auto-registered)
17
- Yard::Fence::RakeTask.new unless Rake::Task.task_defined?("yard:fence:prepare")
15
+ require "yard/fence"
16
+ Yard::Fence.install_rake_tasks!(:yard)
18
17
  rescue LoadError
19
- # yard-fence not available or doesn't have rake_task - that's fine
18
+ # yard-fence not available - that's fine
20
19
  end
21
20
 
22
- YARD::Rake::YardocTask.new(:yard) do |t|
23
- t.files = [
24
- # Source Splats (alphabetical)
25
- "lib/**/*.rb",
26
- "-", # source and extra docs are separated by "-"
27
- # Extra Files (alphabetical)
28
- "*.cff",
29
- "*.md",
30
- "*.txt",
31
- # NOTE: checksums/**/* removed - it's in .yardignore and was causing
32
- # file.<gem>.html pages to be generated for each checksum file
33
- "REEK",
34
- "sig/**/*.rbs",
35
- ]
36
-
37
- # No need for this, due to plugin load in .yardopts
38
- # require "yard-junk/rake"
39
- # YardJunk::Rake.define_task
21
+ begin
22
+ require "yard/timekeeper"
23
+ Yard::Timekeeper.install_rake_tasks!(:yard)
24
+ rescue LoadError
25
+ # yard-timekeeper not available - that's fine
40
26
  end
41
- Kettle::Dev.register_default("yard")
42
27
  rescue LoadError
43
28
  warn("[kettle-dev][yard.rake] failed to load yard") if Kettle::Dev::DEBUGGING
44
29
  desc("(stub) yard is unavailable")
@@ -29,7 +29,7 @@ module Kettle
29
29
  private
30
30
 
31
31
  # Emit a debug log line when kettle-dev debugging is enabled.
32
- # Controlled by KETTLE_DEV_DEBUG=true (or DEBUG=true as fallback).
32
+ # Controlled by KETTLE_DEV_DEBUG=true.
33
33
  # @param msg [String]
34
34
  # @return [void]
35
35
  def debug_log(msg)
@@ -215,14 +215,14 @@ module Kettle
215
215
  if !backers_changed && !sponsors_changed
216
216
  if b_start == :not_found && s_start == :not_found
217
217
  ts = tag_strings
218
- warn("No recognized Open Collective tags found in #{@readme_path}. Expected one or more of: " \
218
+ warn("No recognized Open Collective tags found in #{Kettle::Dev.display_path(@readme_path)}. Expected one or more of: " \
219
219
  "#{ts[:generic_start]}/#{ts[:generic_end]}, #{ts[:individuals_start]}/#{ts[:individuals_end]}, #{ts[:orgs_start]}/#{ts[:orgs_end]}.")
220
220
  debug_log("Missing tags: looked for #{ts}")
221
221
  # Do not exit the process during tests or library use; just return.
222
222
  return
223
223
  end
224
224
  debug_log("No changes detected after processing; Backers=#{backers.size}, Sponsors=#{sponsors.size}, ExtraTiers=#{extra_map.keys.size}")
225
- puts "No changes to backers or sponsors sections in #{@readme_path}."
225
+ puts "No changes to backers or sponsors sections in #{Kettle::Dev.display_path(@readme_path)}."
226
226
  return
227
227
  end
228
228
 
@@ -230,7 +230,7 @@ module Kettle
230
230
  msgs = []
231
231
  msgs << "backers" if backers_changed
232
232
  msgs << "sponsors" if sponsors_changed
233
- puts "Updated #{msgs.join(" and ")} section#{{true => "s", false => ""}[msgs.size > 1]} in #{@readme_path}."
233
+ puts "Updated #{msgs.join(" and ")} section#{{true => "s", false => ""}[msgs.size > 1]} in #{Kettle::Dev.display_path(@readme_path)}."
234
234
 
235
235
  # Compose and perform commit with mentions if in a git repo
236
236
  perform_git_commit(new_backers, new_sponsors) if git_repo? && (backers_changed || sponsors_changed)