kettle-dev 1.2.1 → 1.2.3
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
- checksums.yaml.gz.sig +0 -0
- data/.rubocop_rspec.yml +3 -0
- data/CHANGELOG.md +42 -8
- data/README.md +1 -1
- data/README.md.example +1 -1
- data/Rakefile.example +1 -1
- data/exe/kettle-changelog +18 -3
- data/kettle-dev.gemspec.example +3 -3
- data/lib/kettle/dev/changelog_cli.rb +88 -22
- data/lib/kettle/dev/prism_appraisals.rb +45 -0
- data/lib/kettle/dev/prism_gemfile.rb +41 -0
- data/lib/kettle/dev/source_merger.rb +208 -44
- data/lib/kettle/dev/template_helpers.rb +45 -0
- data/lib/kettle/dev/version.rb +1 -1
- data.tar.gz.sig +0 -0
- metadata +8 -8
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cad2d78d297d0e69cd016624ff87dba0c5fa4bd3af5399d7c97c4e8fcc2af82a
|
|
4
|
+
data.tar.gz: '08f58025922178025f79d3ef3004b35f9207e23764c490587ec3ed5dfc240ca5'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 12563697c8b719fc13ded6b096fcd92266dbb98f9f8c87a21aad756bebbe2065f7dc17e2be982d1a3765a15c8c3b8cbd0eec88642fc7fb90600e1be88c4db149
|
|
7
|
+
data.tar.gz: e3ce95406792e64d08da4db003a5771c45c975155c417176e10972e13ad0e012ed006aa9193b81b1fbecc96d59edb752cba80a26a538c60f824c52228e6110e9
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/.rubocop_rspec.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -20,13 +20,6 @@ Please file a bug if you notice a violation of semantic versioning.
|
|
|
20
20
|
|
|
21
21
|
### Added
|
|
22
22
|
|
|
23
|
-
- Prism AST-based manipulation of ruby during templating
|
|
24
|
-
- Gemfiles
|
|
25
|
-
- gemspecs
|
|
26
|
-
- .simplecov
|
|
27
|
-
- Stop rescuing Exception in certain scenarios (just StandardError)
|
|
28
|
-
- Refactored logging logic and documentation
|
|
29
|
-
|
|
30
23
|
### Changed
|
|
31
24
|
|
|
32
25
|
### Deprecated
|
|
@@ -37,6 +30,43 @@ Please file a bug if you notice a violation of semantic versioning.
|
|
|
37
30
|
|
|
38
31
|
### Security
|
|
39
32
|
|
|
33
|
+
## [1.2.3] - 2025-11-28
|
|
34
|
+
|
|
35
|
+
- TAG: [v1.2.3][1.2.3t]
|
|
36
|
+
- COVERAGE: 93.43% -- 4681/5010 lines in 31 files
|
|
37
|
+
- BRANCH COVERAGE: 76.63% -- 1912/2495 branches in 31 files
|
|
38
|
+
- 70.55% documented
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Fixed Gemfile parsing to properly deduplicate comments across multiple template runs
|
|
43
|
+
- Implemented two-pass comment deduplication: sequences first, then individual lines
|
|
44
|
+
- Magic comments (frozen_string_literal, encoding, etc.) are now properly deduplicated by content, not line position
|
|
45
|
+
- File-level comments are deduplicated while preserving leading comments attached to statements
|
|
46
|
+
- Ensures idempotent behavior when running templating multiple times on the same file
|
|
47
|
+
- Prevents accumulation of duplicate frozen_string_literal comments and comment blocks
|
|
48
|
+
|
|
49
|
+
## [1.2.2] - 2025-11-27
|
|
50
|
+
|
|
51
|
+
- TAG: [v1.2.2][1.2.2t]
|
|
52
|
+
- COVERAGE: 93.28% -- 4596/4927 lines in 31 files
|
|
53
|
+
- BRANCH COVERAGE: 76.45% -- 1883/2463 branches in 31 files
|
|
54
|
+
- 70.00% documented
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- Prism AST-based manipulation of ruby during templating
|
|
59
|
+
- Gemfiles
|
|
60
|
+
- gemspecs
|
|
61
|
+
- .simplecov
|
|
62
|
+
- Stop rescuing Exception in certain scenarios (just StandardError)
|
|
63
|
+
- Refactored logging logic and documentation
|
|
64
|
+
- Prevent self-referential gemfile injection
|
|
65
|
+
- in Gemfiles, gemspecs, and Appraisals
|
|
66
|
+
- Improve reliability of coverage and documentation stats
|
|
67
|
+
- in the changelog version heading
|
|
68
|
+
- fails hard when unable to generate stats, unless `--no-strict` provided
|
|
69
|
+
|
|
40
70
|
## [1.2.1] - 2025-11-25
|
|
41
71
|
|
|
42
72
|
- TAG: [v1.2.0][1.2.0t]
|
|
@@ -1477,7 +1507,11 @@ Please file a bug if you notice a violation of semantic versioning.
|
|
|
1477
1507
|
- Selecting will run the selected workflow via `act`
|
|
1478
1508
|
- This may move to its own gem in the future.
|
|
1479
1509
|
|
|
1480
|
-
[Unreleased]: https://github.com/kettle-rb/kettle-dev/compare/v1.2.
|
|
1510
|
+
[Unreleased]: https://github.com/kettle-rb/kettle-dev/compare/v1.2.3...HEAD
|
|
1511
|
+
[1.2.3]: https://github.com/kettle-rb/kettle-dev/compare/v1.2.2...v1.2.3
|
|
1512
|
+
[1.2.3t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.2.3
|
|
1513
|
+
[1.2.2]: https://github.com/kettle-rb/kettle-dev/compare/v1.2.1...v1.2.2
|
|
1514
|
+
[1.2.2t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.2.2
|
|
1481
1515
|
[1.2.0]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.60...v1.2.0
|
|
1482
1516
|
[1.2.0t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.2.0
|
|
1483
1517
|
[1.1.60]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.59...v1.1.60
|
data/README.md
CHANGED
|
@@ -1026,7 +1026,7 @@ Thanks for RTFM. ☺️
|
|
|
1026
1026
|
[📌gitmoji]: https://gitmoji.dev
|
|
1027
1027
|
[📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
|
|
1028
1028
|
[🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
1029
|
-
[🧮kloc-img]: https://img.shields.io/badge/KLOC-
|
|
1029
|
+
[🧮kloc-img]: https://img.shields.io/badge/KLOC-5.010-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
|
|
1030
1030
|
[🔐security]: SECURITY.md
|
|
1031
1031
|
[🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
|
|
1032
1032
|
[📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
|
data/README.md.example
CHANGED
|
@@ -548,7 +548,7 @@ Thanks for RTFM. ☺️
|
|
|
548
548
|
[📌gitmoji]: https://gitmoji.dev
|
|
549
549
|
[📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
|
|
550
550
|
[🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
551
|
-
[🧮kloc-img]: https://img.shields.io/badge/KLOC-
|
|
551
|
+
[🧮kloc-img]: https://img.shields.io/badge/KLOC-5.010-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
|
|
552
552
|
[🔐security]: SECURITY.md
|
|
553
553
|
[🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
|
|
554
554
|
[📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
|
data/Rakefile.example
CHANGED
data/exe/kettle-changelog
CHANGED
|
@@ -50,22 +50,37 @@ end
|
|
|
50
50
|
begin
|
|
51
51
|
if ARGV.include?("-h") || ARGV.include?("--help")
|
|
52
52
|
puts <<~USAGE
|
|
53
|
-
Usage: kettle-changelog
|
|
53
|
+
Usage: kettle-changelog [--no-strict]
|
|
54
54
|
|
|
55
55
|
Generates a new CHANGELOG.md entry for the current version detected from lib/**/version.rb.
|
|
56
56
|
Moves entries from [Unreleased] into the new section, adds coverage and documentation stats,
|
|
57
57
|
and updates bottom link references to GitHub style, adding new compare/tag links.
|
|
58
58
|
|
|
59
|
+
Options:
|
|
60
|
+
--no-strict Allow missing coverage and yard data (warnings only, no errors)
|
|
61
|
+
|
|
62
|
+
Environment:
|
|
63
|
+
K_CHANGELOG_STRICT=false Disable strict mode (equivalent to --no-strict flag)
|
|
64
|
+
|
|
59
65
|
Prerequisites:
|
|
60
|
-
- coverage/coverage.json present (run:
|
|
66
|
+
- coverage/coverage.json present (run: bin/rake coverage to generate)
|
|
61
67
|
- yard installed and available via bin/yard
|
|
68
|
+
|
|
69
|
+
By default (strict mode), if coverage.json or yard stats are missing, the script will:
|
|
70
|
+
1. Attempt to generate them by running bin/rake coverage and bin/rake yard
|
|
71
|
+
2. Fail with an error if generation fails or data is still unavailable
|
|
72
|
+
|
|
73
|
+
Use --no-strict or K_CHANGELOG_STRICT=false to allow missing data (backward compatible behavior).
|
|
62
74
|
USAGE
|
|
63
75
|
exit(0)
|
|
64
76
|
end
|
|
65
77
|
end
|
|
66
78
|
|
|
67
79
|
begin
|
|
68
|
-
|
|
80
|
+
# Determine if strict mode is enabled (default: true)
|
|
81
|
+
strict_mode = !ARGV.include?("--no-strict") && ENV.fetch("K_CHANGELOG_STRICT", "true").downcase != "false"
|
|
82
|
+
|
|
83
|
+
Kettle::Dev::ChangelogCLI.new(strict: strict_mode).run
|
|
69
84
|
rescue LoadError => e
|
|
70
85
|
warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
|
|
71
86
|
warn(e.backtrace.join("\n")) if ENV["DEBUG"]
|
data/kettle-dev.gemspec.example
CHANGED
|
@@ -114,10 +114,10 @@ Gem::Specification.new do |spec|
|
|
|
114
114
|
# and preferably a modular one (see gemfiles/modular/*.gemfile).
|
|
115
115
|
|
|
116
116
|
# Dev, Test, & Release Tasks
|
|
117
|
-
spec.add_development_dependency("{KETTLE|DEV|GEM}", "~> 1.
|
|
117
|
+
spec.add_development_dependency("{KETTLE|DEV|GEM}", "~> 1.2") # ruby >= 2.3.0
|
|
118
118
|
|
|
119
119
|
# Security
|
|
120
|
-
spec.add_development_dependency("bundler-audit", "~> 0.9.
|
|
120
|
+
spec.add_development_dependency("bundler-audit", "~> 0.9.3") # ruby >= 2.0.0
|
|
121
121
|
|
|
122
122
|
# Tasks
|
|
123
123
|
spec.add_development_dependency("rake", "~> 13.0") # ruby >= 2.2.0
|
|
@@ -131,7 +131,7 @@ Gem::Specification.new do |spec|
|
|
|
131
131
|
|
|
132
132
|
# Releasing
|
|
133
133
|
spec.add_development_dependency("ruby-progressbar", "~> 1.13") # ruby >= 0
|
|
134
|
-
spec.add_development_dependency("stone_checksums", "~> 1.0", ">= 1.0.
|
|
134
|
+
spec.add_development_dependency("stone_checksums", "~> 1.0", ">= 1.0.3") # ruby >= 2.2.0
|
|
135
135
|
|
|
136
136
|
# Git integration (optional)
|
|
137
137
|
# The 'git' gem is optional; kettle-dev falls back to shelling out to `git` if it is not present.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
3
5
|
module Kettle
|
|
4
6
|
module Dev
|
|
5
7
|
# CLI for updating CHANGELOG.md with new version sections
|
|
@@ -11,10 +13,12 @@ module Kettle
|
|
|
11
13
|
|
|
12
14
|
# Initialize the changelog CLI
|
|
13
15
|
# Sets up paths for CHANGELOG.md and coverage.json
|
|
14
|
-
|
|
16
|
+
# @param strict [Boolean] when true (default), require coverage and yard data; raise errors if unavailable
|
|
17
|
+
def initialize(strict: true)
|
|
15
18
|
@root = Kettle::Dev::CIHelpers.project_root
|
|
16
19
|
@changelog_path = File.join(@root, "CHANGELOG.md")
|
|
17
20
|
@coverage_path = File.join(@root, "coverage", "coverage.json")
|
|
21
|
+
@strict = strict
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
# Main entry point to update CHANGELOG.md
|
|
@@ -215,11 +219,49 @@ module Kettle
|
|
|
215
219
|
end
|
|
216
220
|
|
|
217
221
|
def coverage_lines
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
+
if @strict
|
|
223
|
+
# Always generate fresh coverage data in strict mode
|
|
224
|
+
# Delete old coverage files to ensure we get current data
|
|
225
|
+
coverage_dir = File.dirname(@coverage_path)
|
|
226
|
+
if Dir.exist?(coverage_dir)
|
|
227
|
+
puts "Cleaning old coverage data from #{coverage_dir}..."
|
|
228
|
+
Dir.glob(File.join(coverage_dir, "*")).each do |file|
|
|
229
|
+
File.delete(file) if File.file?(file)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
puts "Generating fresh coverage data by running: bin/rake coverage"
|
|
234
|
+
|
|
235
|
+
# Run bin/rake coverage to generate coverage.json
|
|
236
|
+
rake_cmd = File.join(@root, "bin", "rake")
|
|
237
|
+
unless File.executable?(rake_cmd)
|
|
238
|
+
raise "bin/rake not found or not executable; cannot generate coverage data"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Run the command exactly as the user would run it manually
|
|
242
|
+
# The coverage task knows how to configure itself properly
|
|
243
|
+
success = system(rake_cmd, "coverage", chdir: @root)
|
|
244
|
+
|
|
245
|
+
unless success
|
|
246
|
+
raise "bin/rake coverage failed with exit status #{$?.exitstatus || "unknown"}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
puts "Coverage generation complete."
|
|
250
|
+
|
|
251
|
+
# Check if coverage.json was generated
|
|
252
|
+
unless File.file?(@coverage_path)
|
|
253
|
+
raise "Coverage JSON not found at #{@coverage_path} after running bin/rake coverage"
|
|
254
|
+
end
|
|
255
|
+
else
|
|
256
|
+
# Non-strict mode: check if coverage.json exists, warn if not
|
|
257
|
+
unless File.file?(@coverage_path)
|
|
258
|
+
warn("Coverage JSON not found at #{@coverage_path}.")
|
|
259
|
+
warn("Run: bin/rake coverage to generate it")
|
|
260
|
+
return [nil, nil]
|
|
261
|
+
end
|
|
222
262
|
end
|
|
263
|
+
|
|
264
|
+
# Parse the coverage data
|
|
223
265
|
data = JSON.parse(File.read(@coverage_path))
|
|
224
266
|
files = data["coverage"] || {}
|
|
225
267
|
file_count = 0
|
|
@@ -252,31 +294,55 @@ module Kettle
|
|
|
252
294
|
line_str = format("COVERAGE: %.2f%% -- %d/%d lines in %d files", line_pct, covered_lines, total_lines, file_count)
|
|
253
295
|
branch_str = format("BRANCH COVERAGE: %.2f%% -- %d/%d branches in %d files", branch_pct, covered_branches, total_branches, file_count)
|
|
254
296
|
[line_str, branch_str]
|
|
297
|
+
rescue JSON::ParserError => e
|
|
298
|
+
if @strict
|
|
299
|
+
raise "Failed to parse coverage JSON at #{@coverage_path}: #{e.class}: #{e.message}"
|
|
300
|
+
else
|
|
301
|
+
warn("Failed to parse coverage: #{e.class}: #{e.message}")
|
|
302
|
+
[nil, nil]
|
|
303
|
+
end
|
|
255
304
|
rescue StandardError => e
|
|
256
|
-
|
|
257
|
-
|
|
305
|
+
if @strict
|
|
306
|
+
raise "Failed to get coverage data: #{e.class}: #{e.message}"
|
|
307
|
+
else
|
|
308
|
+
warn("Failed to get coverage data: #{e.class}: #{e.message}")
|
|
309
|
+
[nil, nil]
|
|
310
|
+
end
|
|
258
311
|
end
|
|
259
312
|
|
|
260
313
|
def yard_percent_documented
|
|
261
314
|
cmd = File.join(@root, "bin", "yard")
|
|
262
315
|
unless File.executable?(cmd)
|
|
263
|
-
|
|
264
|
-
|
|
316
|
+
if @strict
|
|
317
|
+
raise "bin/yard not found or not executable; ensure yard is installed via bundler"
|
|
318
|
+
else
|
|
319
|
+
warn("bin/yard not found or not executable; ensure yard is installed via bundler")
|
|
320
|
+
return
|
|
321
|
+
end
|
|
265
322
|
end
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
line
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
323
|
+
|
|
324
|
+
begin
|
|
325
|
+
# Run bin/yard to get documentation percentage
|
|
326
|
+
out, _ = Open3.capture2(cmd, {chdir: @root})
|
|
327
|
+
# Look for a line containing e.g., "95.35% documented"
|
|
328
|
+
line = out.lines.find { |l| l =~ /\d+(?:\.\d+)?%\s+documented/ }
|
|
329
|
+
|
|
330
|
+
if line
|
|
331
|
+
line.strip
|
|
332
|
+
elsif @strict
|
|
333
|
+
raise "Could not find documented percentage in bin/yard output"
|
|
334
|
+
else
|
|
335
|
+
warn("Could not find documented percentage in bin/yard output.")
|
|
336
|
+
nil
|
|
337
|
+
end
|
|
338
|
+
rescue StandardError => e
|
|
339
|
+
if @strict
|
|
340
|
+
raise "Failed to run bin/yard: #{e.class}: #{e.message}"
|
|
341
|
+
else
|
|
342
|
+
warn("Failed to run bin/yard: #{e.class}: #{e.message}")
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
276
345
|
end
|
|
277
|
-
rescue StandardError => e
|
|
278
|
-
warn("Failed to run bin/yard: #{e.class}: #{e.message}")
|
|
279
|
-
nil
|
|
280
346
|
end
|
|
281
347
|
|
|
282
348
|
# Transform legacy release headings that include a tag suffix, e.g.:
|
|
@@ -301,6 +301,51 @@ module Kettle
|
|
|
301
301
|
statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
302
302
|
statements.compact.map { |stmt| {node: stmt, inline_comments: [], leading_comments: []} }
|
|
303
303
|
end
|
|
304
|
+
|
|
305
|
+
# Remove gem calls that reference the given gem name (to prevent self-dependency).
|
|
306
|
+
# Works by locating gem() call nodes within appraise blocks where the first argument matches gem_name.
|
|
307
|
+
# @param content [String] Appraisals content
|
|
308
|
+
# @param gem_name [String] the gem name to remove
|
|
309
|
+
# @return [String] modified content with self-referential gem calls removed
|
|
310
|
+
def remove_gem_dependency(content, gem_name)
|
|
311
|
+
return content if gem_name.to_s.strip.empty?
|
|
312
|
+
|
|
313
|
+
result = PrismUtils.parse_with_comments(content)
|
|
314
|
+
root = result.value
|
|
315
|
+
return content unless root&.statements&.body
|
|
316
|
+
|
|
317
|
+
out = content.dup
|
|
318
|
+
|
|
319
|
+
# Iterate through all appraise blocks
|
|
320
|
+
root.statements.body.each do |node|
|
|
321
|
+
next unless appraise_call?(node)
|
|
322
|
+
next unless node.block&.body
|
|
323
|
+
|
|
324
|
+
body_stmts = PrismUtils.extract_statements(node.block.body)
|
|
325
|
+
|
|
326
|
+
# Find gem call nodes within this appraise block where first argument matches gem_name
|
|
327
|
+
body_stmts.each do |stmt|
|
|
328
|
+
next unless stmt.is_a?(Prism::CallNode) && stmt.name == :gem
|
|
329
|
+
|
|
330
|
+
first_arg = stmt.arguments&.arguments&.first
|
|
331
|
+
arg_val = begin
|
|
332
|
+
PrismUtils.extract_literal_value(first_arg)
|
|
333
|
+
rescue StandardError
|
|
334
|
+
nil
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
if arg_val && arg_val.to_s == gem_name.to_s
|
|
338
|
+
# Remove this gem call from content
|
|
339
|
+
out = out.sub(stmt.slice, "")
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
out
|
|
345
|
+
rescue StandardError => e
|
|
346
|
+
Kettle::Dev.debug_error(e, __method__) if defined?(Kettle::Dev.debug_error)
|
|
347
|
+
content
|
|
348
|
+
end
|
|
304
349
|
end
|
|
305
350
|
end
|
|
306
351
|
end
|
|
@@ -131,6 +131,47 @@ module Kettle
|
|
|
131
131
|
end
|
|
132
132
|
dest_content
|
|
133
133
|
end
|
|
134
|
+
|
|
135
|
+
# Remove gem calls that reference the given gem name (to prevent self-dependency).
|
|
136
|
+
# Works by locating gem() call nodes where the first argument matches gem_name.
|
|
137
|
+
# @param content [String] Gemfile-like content
|
|
138
|
+
# @param gem_name [String] the gem name to remove
|
|
139
|
+
# @return [String] modified content with self-referential gem calls removed
|
|
140
|
+
def remove_gem_dependency(content, gem_name)
|
|
141
|
+
return content if gem_name.to_s.strip.empty?
|
|
142
|
+
|
|
143
|
+
result = PrismUtils.parse_with_comments(content)
|
|
144
|
+
stmts = PrismUtils.extract_statements(result.value.statements)
|
|
145
|
+
|
|
146
|
+
# Find gem call nodes where first argument matches gem_name
|
|
147
|
+
gem_nodes = stmts.select do |n|
|
|
148
|
+
next false unless n.is_a?(Prism::CallNode) && n.name == :gem
|
|
149
|
+
|
|
150
|
+
first_arg = n.arguments&.arguments&.first
|
|
151
|
+
arg_val = begin
|
|
152
|
+
PrismUtils.extract_literal_value(first_arg)
|
|
153
|
+
rescue StandardError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
arg_val && arg_val.to_s == gem_name.to_s
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Remove each matching gem call from content
|
|
160
|
+
out = content.dup
|
|
161
|
+
gem_nodes.each do |gn|
|
|
162
|
+
# Remove the entire line(s) containing this node
|
|
163
|
+
out = out.sub(gn.slice, "")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
out
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
if defined?(Kettle::Dev) && Kettle::Dev.respond_to?(:debug_error)
|
|
169
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
170
|
+
else
|
|
171
|
+
Kernel.warn("[#{__method__}] #{e.class}: #{e.message}")
|
|
172
|
+
end
|
|
173
|
+
content
|
|
174
|
+
end
|
|
134
175
|
end
|
|
135
176
|
end
|
|
136
177
|
end
|
|
@@ -48,7 +48,7 @@ module Kettle
|
|
|
48
48
|
content =
|
|
49
49
|
case strategy
|
|
50
50
|
when :skip
|
|
51
|
-
src_with_reminder
|
|
51
|
+
normalize_source(src_with_reminder)
|
|
52
52
|
when :replace
|
|
53
53
|
normalize_source(src_with_reminder)
|
|
54
54
|
when :append
|
|
@@ -81,17 +81,22 @@ module Kettle
|
|
|
81
81
|
[before, snippet, after].join
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# Normalize source code
|
|
84
|
+
# Normalize source code by parsing and rebuilding to deduplicate comments
|
|
85
85
|
#
|
|
86
86
|
# @param source [String] Ruby source code
|
|
87
|
-
# @return [String] Normalized source with trailing newline
|
|
87
|
+
# @return [String] Normalized source with trailing newline and deduplicated comments
|
|
88
88
|
# @api private
|
|
89
89
|
def normalize_source(source)
|
|
90
90
|
parse_result = PrismUtils.parse_with_comments(source)
|
|
91
91
|
return ensure_trailing_newline(source) unless parse_result.success?
|
|
92
92
|
|
|
93
|
-
#
|
|
94
|
-
|
|
93
|
+
# Extract and deduplicate comments
|
|
94
|
+
magic_comments = extract_magic_comments(parse_result)
|
|
95
|
+
file_leading_comments = extract_file_leading_comments(parse_result)
|
|
96
|
+
node_infos = extract_nodes_with_comments(parse_result)
|
|
97
|
+
|
|
98
|
+
# Rebuild source with deduplicated comments
|
|
99
|
+
build_source_from_nodes(node_infos, magic_comments: magic_comments, file_leading_comments: file_leading_comments)
|
|
95
100
|
end
|
|
96
101
|
|
|
97
102
|
def reminder_present?(content)
|
|
@@ -231,8 +236,8 @@ module Kettle
|
|
|
231
236
|
end
|
|
232
237
|
|
|
233
238
|
def prism_merge(src_content, dest_content)
|
|
234
|
-
src_result = PrismUtils.parse_with_comments(src_content)
|
|
235
|
-
dest_result = PrismUtils.parse_with_comments(dest_content)
|
|
239
|
+
src_result = Kettle::Dev::PrismUtils.parse_with_comments(src_content)
|
|
240
|
+
dest_result = Kettle::Dev::PrismUtils.parse_with_comments(dest_content)
|
|
236
241
|
|
|
237
242
|
# If src parsing failed, return src unchanged to avoid losing content
|
|
238
243
|
unless src_result.success?
|
|
@@ -245,11 +250,48 @@ module Kettle
|
|
|
245
250
|
|
|
246
251
|
merged_nodes = yield(src_nodes, dest_nodes, src_result, dest_result)
|
|
247
252
|
|
|
248
|
-
# Extract
|
|
249
|
-
|
|
253
|
+
# Extract and deduplicate comments from src and dest SEPARATELY
|
|
254
|
+
# This allows sequence detection to work within each source
|
|
255
|
+
src_tuples = create_comment_tuples(src_result)
|
|
256
|
+
src_deduplicated = deduplicate_comment_sequences(src_tuples)
|
|
257
|
+
|
|
258
|
+
dest_tuples = dest_result.success? ? create_comment_tuples(dest_result) : []
|
|
259
|
+
dest_deduplicated = deduplicate_comment_sequences(dest_tuples)
|
|
260
|
+
|
|
261
|
+
# Now merge the deduplicated tuples by hash+type only (ignore line numbers)
|
|
262
|
+
seen_hash_type = Set.new
|
|
263
|
+
final_tuples = []
|
|
264
|
+
|
|
265
|
+
# Add all deduplicated src tuples
|
|
266
|
+
src_deduplicated.each do |tuple|
|
|
267
|
+
hash_val = tuple[0]
|
|
268
|
+
type = tuple[1]
|
|
269
|
+
key = [hash_val, type]
|
|
270
|
+
unless seen_hash_type.include?(key)
|
|
271
|
+
final_tuples << tuple
|
|
272
|
+
seen_hash_type << key
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Add deduplicated dest tuples that don't duplicate src (by hash+type)
|
|
277
|
+
dest_deduplicated.each do |tuple|
|
|
278
|
+
hash_val = tuple[0]
|
|
279
|
+
type = tuple[1]
|
|
280
|
+
key = [hash_val, type]
|
|
281
|
+
unless seen_hash_type.include?(key)
|
|
282
|
+
final_tuples << tuple
|
|
283
|
+
seen_hash_type << key
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Extract magic and file-level comments from final merged tuples
|
|
288
|
+
magic_comments = final_tuples
|
|
289
|
+
.select { |tuple| tuple[1] == :magic }
|
|
290
|
+
.map { |tuple| tuple[2] }
|
|
250
291
|
|
|
251
|
-
|
|
252
|
-
|
|
292
|
+
file_leading_comments = final_tuples
|
|
293
|
+
.select { |tuple| tuple[1] == :file_level }
|
|
294
|
+
.map { |tuple| tuple[2] }
|
|
253
295
|
|
|
254
296
|
build_source_from_nodes(merged_nodes, magic_comments: magic_comments, file_leading_comments: file_leading_comments)
|
|
255
297
|
end
|
|
@@ -257,45 +299,155 @@ module Kettle
|
|
|
257
299
|
def extract_magic_comments(parse_result)
|
|
258
300
|
return [] unless parse_result.success?
|
|
259
301
|
|
|
260
|
-
|
|
261
|
-
|
|
302
|
+
tuples = create_comment_tuples(parse_result)
|
|
303
|
+
deduplicated = deduplicate_comment_sequences(tuples)
|
|
262
304
|
|
|
263
|
-
#
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
305
|
+
# Filter to only magic comments and return their text
|
|
306
|
+
deduplicated
|
|
307
|
+
.select { |tuple| tuple[1] == :magic }
|
|
308
|
+
.map { |tuple| tuple[2] }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Create a tuple for each comment: [hash, type, text, line_number]
|
|
312
|
+
# where type is one of: :magic, :file_level, :leading
|
|
313
|
+
# (inline comments are handled with their associated statements)
|
|
314
|
+
def create_comment_tuples(parse_result)
|
|
315
|
+
return [] unless parse_result.success?
|
|
316
|
+
|
|
317
|
+
statements = PrismUtils.extract_statements(parse_result.value.statements)
|
|
318
|
+
first_stmt_line = statements.any? ? statements.first.location.start_line : Float::INFINITY
|
|
319
|
+
|
|
320
|
+
tuples = []
|
|
321
|
+
|
|
322
|
+
parse_result.comments.each do |comment|
|
|
323
|
+
comment_line = comment.location.start_line
|
|
324
|
+
comment_text = comment.slice.strip
|
|
325
|
+
|
|
326
|
+
# Determine comment type - magic comments are identified by content, not line number
|
|
327
|
+
type = if is_magic_comment?(comment_text)
|
|
328
|
+
:magic
|
|
329
|
+
elsif comment_line < first_stmt_line
|
|
330
|
+
:file_level
|
|
331
|
+
else
|
|
332
|
+
# This will be handled as a leading or inline comment for a statement
|
|
333
|
+
:leading
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Create hash from normalized comment text (ignoring trailing whitespace)
|
|
337
|
+
comment_hash = comment_text.hash
|
|
338
|
+
|
|
339
|
+
tuples << [comment_hash, type, comment.slice.rstrip, comment_line]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
tuples
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def is_magic_comment?(text)
|
|
346
|
+
text.include?("frozen_string_literal:") ||
|
|
347
|
+
text.include?("encoding:") ||
|
|
348
|
+
text.include?("warn_indent:") ||
|
|
349
|
+
text.include?("shareable_constant_value:")
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Two-pass deduplication:
|
|
353
|
+
# Pass 1: Deduplicate multi-line sequences
|
|
354
|
+
# Pass 2: Deduplicate single-line duplicates
|
|
355
|
+
def deduplicate_comment_sequences(tuples)
|
|
356
|
+
return [] if tuples.empty?
|
|
357
|
+
|
|
358
|
+
# Group tuples by type
|
|
359
|
+
by_type = tuples.group_by { |tuple| tuple[1] }
|
|
360
|
+
|
|
361
|
+
result = []
|
|
362
|
+
|
|
363
|
+
[:magic, :file_level, :leading].each do |type|
|
|
364
|
+
type_tuples = by_type[type] || []
|
|
365
|
+
next if type_tuples.empty?
|
|
366
|
+
|
|
367
|
+
# Pass 1: Remove duplicate sequences
|
|
368
|
+
after_pass1 = deduplicate_sequences_pass1(type_tuples)
|
|
369
|
+
|
|
370
|
+
# Pass 2: Remove single-line duplicates
|
|
371
|
+
after_pass2 = deduplicate_singles_pass2(after_pass1)
|
|
372
|
+
|
|
373
|
+
result.concat(after_pass2)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
result
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Pass 1: Find and remove duplicate multi-line comment sequences
|
|
380
|
+
# A sequence is defined by consecutive comments (ignoring blank lines in between)
|
|
381
|
+
def deduplicate_sequences_pass1(tuples)
|
|
382
|
+
return tuples if tuples.length <= 1
|
|
383
|
+
|
|
384
|
+
# Group tuples into sequences (consecutive comments, allowing gaps for blank lines)
|
|
385
|
+
sequences = []
|
|
386
|
+
current_seq = []
|
|
387
|
+
prev_line = nil
|
|
388
|
+
|
|
389
|
+
tuples.each do |tuple|
|
|
390
|
+
line_num = tuple[3]
|
|
391
|
+
|
|
392
|
+
# If this is consecutive with previous (allowing reasonable gaps for blank lines)
|
|
393
|
+
if prev_line.nil? || (line_num - prev_line) <= 3
|
|
394
|
+
current_seq << tuple
|
|
395
|
+
else
|
|
396
|
+
# Start new sequence
|
|
397
|
+
sequences << current_seq if current_seq.any?
|
|
398
|
+
current_seq = [tuple]
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
prev_line = line_num
|
|
402
|
+
end
|
|
403
|
+
sequences << current_seq if current_seq.any?
|
|
404
|
+
|
|
405
|
+
# Find duplicate sequences by comparing hash signatures
|
|
406
|
+
seen_seq_signatures = Set.new
|
|
407
|
+
unique_tuples = []
|
|
408
|
+
|
|
409
|
+
sequences.each do |seq|
|
|
410
|
+
# Create signature from hashes and sequence length
|
|
411
|
+
seq_signature = seq.map { |t| t[0] }.join(",")
|
|
412
|
+
|
|
413
|
+
unless seen_seq_signatures.include?(seq_signature)
|
|
414
|
+
seen_seq_signatures << seq_signature
|
|
415
|
+
unique_tuples.concat(seq)
|
|
277
416
|
end
|
|
278
417
|
end
|
|
279
418
|
|
|
280
|
-
|
|
419
|
+
unique_tuples
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Pass 2: Remove single-line duplicates from already sequence-deduplicated tuples
|
|
423
|
+
def deduplicate_singles_pass2(tuples)
|
|
424
|
+
return tuples if tuples.length <= 1
|
|
425
|
+
|
|
426
|
+
seen_hashes = Set.new
|
|
427
|
+
unique_tuples = []
|
|
428
|
+
|
|
429
|
+
tuples.each do |tuple|
|
|
430
|
+
comment_hash = tuple[0]
|
|
431
|
+
|
|
432
|
+
unless seen_hashes.include?(comment_hash)
|
|
433
|
+
seen_hashes << comment_hash
|
|
434
|
+
unique_tuples << tuple
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
unique_tuples
|
|
281
439
|
end
|
|
282
440
|
|
|
283
441
|
def extract_file_leading_comments(parse_result)
|
|
284
442
|
return [] unless parse_result.success?
|
|
285
443
|
|
|
286
|
-
|
|
287
|
-
|
|
444
|
+
tuples = create_comment_tuples(parse_result)
|
|
445
|
+
deduplicated = deduplicate_comment_sequences(tuples)
|
|
288
446
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
# but before the first executable statement. These are typically documentation
|
|
294
|
-
# comments describing the file's purpose.
|
|
295
|
-
parse_result.comments.select do |comment|
|
|
296
|
-
comment.location.start_line > 2 &&
|
|
297
|
-
comment.location.start_line < first_stmt_line
|
|
298
|
-
end.map { |comment| comment.slice.rstrip }
|
|
447
|
+
# Filter to only file-level comments and return their text
|
|
448
|
+
deduplicated
|
|
449
|
+
.select { |tuple| tuple[1] == :file_level }
|
|
450
|
+
.map { |tuple| tuple[2] }
|
|
299
451
|
end
|
|
300
452
|
|
|
301
453
|
def extract_nodes_with_comments(parse_result)
|
|
@@ -358,8 +510,6 @@ module Kettle
|
|
|
358
510
|
end
|
|
359
511
|
|
|
360
512
|
def build_source_from_nodes(node_infos, magic_comments: [], file_leading_comments: [])
|
|
361
|
-
return "" if node_infos.empty?
|
|
362
|
-
|
|
363
513
|
lines = []
|
|
364
514
|
|
|
365
515
|
# Add magic comments at the top (frozen_string_literal, etc.)
|
|
@@ -371,9 +521,16 @@ module Kettle
|
|
|
371
521
|
# Add file-level leading comments (comments before first statement)
|
|
372
522
|
if file_leading_comments.any?
|
|
373
523
|
lines.concat(file_leading_comments)
|
|
374
|
-
|
|
524
|
+
# Only add blank line if there are statements following
|
|
525
|
+
lines << "" if node_infos.any?
|
|
375
526
|
end
|
|
376
527
|
|
|
528
|
+
# If there are no statements and no comments, return empty string
|
|
529
|
+
return "" if node_infos.empty? && lines.empty?
|
|
530
|
+
|
|
531
|
+
# If there are only comments and no statements, return the comments
|
|
532
|
+
return lines.join("\n") if node_infos.empty?
|
|
533
|
+
|
|
377
534
|
node_infos.each do |node_info|
|
|
378
535
|
# Add blank lines before this statement (for visual grouping)
|
|
379
536
|
blank_lines = node_info[:blank_lines_before] || 0
|
|
@@ -435,7 +592,14 @@ module Kettle
|
|
|
435
592
|
def restore_custom_leading_comments(dest_content, merged_content)
|
|
436
593
|
block = leading_comment_block(dest_content)
|
|
437
594
|
return merged_content if block.strip.empty?
|
|
438
|
-
|
|
595
|
+
|
|
596
|
+
# Check if the merged content already starts with this block
|
|
597
|
+
# Use normalized comparison to handle whitespace differences
|
|
598
|
+
merged_leading = leading_comment_block(merged_content)
|
|
599
|
+
|
|
600
|
+
# If merged already has the same or more comprehensive leading comments, don't add
|
|
601
|
+
return merged_content if merged_leading.strip == block.strip
|
|
602
|
+
return merged_content if merged_content.include?(block.strip)
|
|
439
603
|
|
|
440
604
|
# Insert after shebang / frozen string literal comments (same place reminder goes)
|
|
441
605
|
insertion_index = reminder_insertion_index(merged_content)
|
|
@@ -298,6 +298,19 @@ module Kettle
|
|
|
298
298
|
end
|
|
299
299
|
end
|
|
300
300
|
|
|
301
|
+
# Apply self-dependency removal for all gem-related files
|
|
302
|
+
# This ensures we don't introduce a self-dependency when templating
|
|
303
|
+
begin
|
|
304
|
+
meta = gemspec_metadata
|
|
305
|
+
gem_name = meta[:gem_name]
|
|
306
|
+
if gem_name && !gem_name.to_s.empty?
|
|
307
|
+
content = remove_self_dependency(content, gem_name, dest_path)
|
|
308
|
+
end
|
|
309
|
+
rescue StandardError => e
|
|
310
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
311
|
+
# If metadata extraction or removal fails, proceed with content as-is
|
|
312
|
+
end
|
|
313
|
+
|
|
301
314
|
write_file(dest_path, content)
|
|
302
315
|
begin
|
|
303
316
|
# Ensure executable bit for git hook scripts when writing under .git-hooks
|
|
@@ -342,6 +355,38 @@ module Kettle
|
|
|
342
355
|
content
|
|
343
356
|
end
|
|
344
357
|
|
|
358
|
+
# Remove self-referential gem dependencies from content based on file type.
|
|
359
|
+
# Applies to gemspec, Gemfile, modular gemfiles, Appraisal.root.gemfile, and Appraisals.
|
|
360
|
+
# @param content [String] file content
|
|
361
|
+
# @param gem_name [String] the gem name to remove
|
|
362
|
+
# @param file_path [String] path to the file (used to determine type)
|
|
363
|
+
# @return [String] content with self-dependencies removed
|
|
364
|
+
def remove_self_dependency(content, gem_name, file_path)
|
|
365
|
+
return content if gem_name.to_s.strip.empty?
|
|
366
|
+
|
|
367
|
+
basename = File.basename(file_path.to_s)
|
|
368
|
+
|
|
369
|
+
begin
|
|
370
|
+
case basename
|
|
371
|
+
when /\.gemspec$/
|
|
372
|
+
# Use PrismGemspec for gemspec files
|
|
373
|
+
Kettle::Dev::PrismGemspec.remove_spec_dependency(content, gem_name)
|
|
374
|
+
when "Gemfile", "Appraisal.root.gemfile", /\.gemfile$/
|
|
375
|
+
# Use PrismGemfile for Gemfile-like files
|
|
376
|
+
Kettle::Dev::PrismGemfile.remove_gem_dependency(content, gem_name)
|
|
377
|
+
when "Appraisals"
|
|
378
|
+
# Use PrismAppraisals for Appraisals files
|
|
379
|
+
Kettle::Dev::PrismAppraisals.remove_gem_dependency(content, gem_name)
|
|
380
|
+
else
|
|
381
|
+
# Return content unchanged for unknown file types
|
|
382
|
+
content
|
|
383
|
+
end
|
|
384
|
+
rescue StandardError => e
|
|
385
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
386
|
+
content
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
345
390
|
# Copy a directory tree, prompting before creating or overwriting.
|
|
346
391
|
# @return [void]
|
|
347
392
|
def copy_dir_with_prompt(src_dir, dest_dir)
|
data/lib/kettle/dev/version.rb
CHANGED
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kettle-dev
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Peter H. Boling
|
|
@@ -57,14 +57,14 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: 0.9.
|
|
60
|
+
version: 0.9.3
|
|
61
61
|
type: :development
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: 0.9.
|
|
67
|
+
version: 0.9.3
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: require_bench
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -142,7 +142,7 @@ dependencies:
|
|
|
142
142
|
version: '1.0'
|
|
143
143
|
- - ">="
|
|
144
144
|
- !ruby/object:Gem::Version
|
|
145
|
-
version: 1.0.
|
|
145
|
+
version: 1.0.3
|
|
146
146
|
type: :development
|
|
147
147
|
prerelease: false
|
|
148
148
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -152,7 +152,7 @@ dependencies:
|
|
|
152
152
|
version: '1.0'
|
|
153
153
|
- - ">="
|
|
154
154
|
- !ruby/object:Gem::Version
|
|
155
|
-
version: 1.0.
|
|
155
|
+
version: 1.0.3
|
|
156
156
|
- !ruby/object:Gem::Dependency
|
|
157
157
|
name: gitmoji-regex
|
|
158
158
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -414,10 +414,10 @@ licenses:
|
|
|
414
414
|
- MIT
|
|
415
415
|
metadata:
|
|
416
416
|
homepage_uri: https://kettle-dev.galtzo.com/
|
|
417
|
-
source_code_uri: https://github.com/kettle-rb/kettle-dev/tree/v1.2.
|
|
418
|
-
changelog_uri: https://github.com/kettle-rb/kettle-dev/blob/v1.2.
|
|
417
|
+
source_code_uri: https://github.com/kettle-rb/kettle-dev/tree/v1.2.3
|
|
418
|
+
changelog_uri: https://github.com/kettle-rb/kettle-dev/blob/v1.2.3/CHANGELOG.md
|
|
419
419
|
bug_tracker_uri: https://github.com/kettle-rb/kettle-dev/issues
|
|
420
|
-
documentation_uri: https://www.rubydoc.info/gems/kettle-dev/1.2.
|
|
420
|
+
documentation_uri: https://www.rubydoc.info/gems/kettle-dev/1.2.3
|
|
421
421
|
funding_uri: https://github.com/sponsors/pboling
|
|
422
422
|
wiki_uri: https://github.com/kettle-rb/kettle-dev/wiki
|
|
423
423
|
news_uri: https://www.railsbling.com/tags/kettle-dev
|
metadata.gz.sig
CHANGED
|
Binary file
|