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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf48cdba2c3cbe1c95eab4c61a4073f7849fda6b7dbc41ec2dedaca59b414242
4
- data.tar.gz: 171598b7227ced01474c97a14a8167d3c6c64785b08daeb6996e64d1ed42973d
3
+ metadata.gz: cad2d78d297d0e69cd016624ff87dba0c5fa4bd3af5399d7c97c4e8fcc2af82a
4
+ data.tar.gz: '08f58025922178025f79d3ef3004b35f9207e23764c490587ec3ed5dfc240ca5'
5
5
  SHA512:
6
- metadata.gz: 224a2b34db6d14760ae6105eb67407baebb25a67d7ac1ccad37eaa01823278d3d6c68e6822430f47d986c756f232ce637df248184ec61ca7a4394df3c4ced4c8
7
- data.tar.gz: f8d133803fdbcf798e05faf1749a3e03efc539d5513142bbe76da9b3954662cf043ae868b79151c0c1cb84129f15b4e3e3af7a9764e301d14134fa352ba38ff5
6
+ metadata.gz: 12563697c8b719fc13ded6b096fcd92266dbb98f9f8c87a21aad756bebbe2065f7dc17e2be982d1a3765a15c8c3b8cbd0eec88642fc7fb90600e1be88c4db149
7
+ data.tar.gz: e3ce95406792e64d08da4db003a5771c45c975155c417176e10972e13ad0e012ed006aa9193b81b1fbecc96d59edb752cba80a26a538c60f824c52228e6110e9
checksums.yaml.gz.sig CHANGED
Binary file
data/.rubocop_rspec.yml CHANGED
@@ -25,6 +25,9 @@ RSpec/ExpectInHook:
25
25
  RSpec/DescribeClass:
26
26
  Exclude:
27
27
  - 'spec/examples/*'
28
+ - 'spec/integration/*'
29
+ - 'spec/system/*'
30
+ - 'spec/e2e/*'
28
31
 
29
32
  RSpec/MultipleMemoizedHelpers:
30
33
  Enabled: false
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.0...HEAD
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-4.308-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
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-4.308-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # kettle-dev Rakefile v1.2.1 - 2025-11-26
3
+ # kettle-dev Rakefile v1.2.3 - 2025-11-28
4
4
  # Ruby 2.3 (Safe Navigation) or higher required
5
5
  #
6
6
  # MIT License (see License.txt)
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: K_SOUP_COV_FORMATTERS="json" bin/rspec)
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
- Kettle::Dev::ChangelogCLI.new.run
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"]
@@ -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.1") # ruby >= 2.3.0
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.2") # ruby >= 2.0.0
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.2") # ruby >= 2.2.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
- def initialize
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
- unless File.file?(@coverage_path)
219
- warn("Coverage JSON not found at #{@coverage_path}.")
220
- warn("Run: K_SOUP_COV_FORMATTERS=\"json\" bin/rspec")
221
- return [nil, nil]
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
- warn("Failed to parse coverage: #{e.class}: #{e.message}")
257
- [nil, nil]
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
- warn("bin/yard not found or not executable; ensure yard is installed via bundler")
264
- return
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
- out, _ = Open3.capture2(cmd)
267
- # Look for a line containing e.g., "95.35% documented"
268
- line = out.lines.find { |l| l =~ /\d+(?:\.\d+)?%\s+documented/ }
269
- if line
270
- line = line.strip
271
- # Return exactly as requested: e.g. "95.35% documented"
272
- line
273
- else
274
- warn("Could not find documented percentage in bin/yard output.")
275
- nil
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 while preserving formatting
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
- # Use Prism's slice to preserve original formatting
94
- ensure_trailing_newline(source)
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 magic comments from source (frozen_string_literal, etc.)
249
- magic_comments = extract_magic_comments(src_result)
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
- # Extract file-level leading comments (comments before first statement)
252
- file_leading_comments = extract_file_leading_comments(src_result)
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
- magic_comments = []
261
- source_lines = parse_result.source.lines
302
+ tuples = create_comment_tuples(parse_result)
303
+ deduplicated = deduplicate_comment_sequences(tuples)
262
304
 
263
- # Magic comments appear at the very top of the file (possibly after shebang)
264
- # They must be on the first or second line
265
- source_lines.first(2).each do |line|
266
- stripped = line.strip
267
- # Check for shebang
268
- if stripped.start_with?("#!")
269
- magic_comments << line.rstrip
270
- # Check for magic comments like frozen_string_literal, encoding, etc.
271
- elsif stripped.start_with?("#") &&
272
- (stripped.include?("frozen_string_literal:") ||
273
- stripped.include?("encoding:") ||
274
- stripped.include?("warn_indent:") ||
275
- stripped.include?("shareable_constant_value:"))
276
- magic_comments << line.rstrip
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
- magic_comments
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
- statements = PrismUtils.extract_statements(parse_result.value.statements)
287
- return [] if statements.empty?
444
+ tuples = create_comment_tuples(parse_result)
445
+ deduplicated = deduplicate_comment_sequences(tuples)
288
446
 
289
- first_stmt = statements.first
290
- first_stmt_line = first_stmt.location.start_line
291
-
292
- # Extract file-level comments that appear after magic comments (line 1-2)
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
- lines << "" # Add blank line after file-level comments
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
- return merged_content if merged_content.start_with?(block)
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)
@@ -6,7 +6,7 @@ module Kettle
6
6
  module Version
7
7
  # The gem version.
8
8
  # @return [String]
9
- VERSION = "1.2.1"
9
+ VERSION = "1.2.3"
10
10
 
11
11
  module_function
12
12
 
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.1
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.2
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.2
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.2
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.2
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.1
418
- changelog_uri: https://github.com/kettle-rb/kettle-dev/blob/v1.2.1/CHANGELOG.md
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.1
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