kettle-dev 2.5.18 → 2.5.19

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: 6ce9b09d7f7b63282d83f98a7599f0e3a1e2f4253c4f342c5a386220ad957a1d
4
- data.tar.gz: 108a6343526873d70684091ae6fddb5b0663644b26a2428dfe89721f29767096
3
+ metadata.gz: a8ffb38694a14518e416b497413025d417137ce7b7cf8673dd31f68aa46c8539
4
+ data.tar.gz: 71d1ff523dc11ba20f72994792ad3edd4ff43a0ceea2c8c84e46e65b77e72977
5
5
  SHA512:
6
- metadata.gz: dc871e4686bc80667419140219bd8655c64375b6d24d556d562275437876f9fefa71bbf6b3dac7fc0c332dd23bdbc4311333700b6b151c683e943424b0ca628d
7
- data.tar.gz: 311013ce7d7751d9d7f6b8ac2ae165a73ea366711af9921ae70f6b2514c6689141f1ac06ab7481429f3e2dba3073b912fa4d9b94c48835a7b896c0e16e1d4c30
6
+ metadata.gz: a7cf27e978644578be5e2839670c4981343b9d84646d59ece70e4d83eb388404dcd52b77c3f90929164ff86f698bc6bfc93ecfa42f8bfc9a2ec342d764030aa3
7
+ data.tar.gz: 59de547a4f7a50905bed8bc66c8b919eca375ff9d4d31bd079371dfee717d540bf800477bae8dad644a2efe7935d7cb44b53e059cb4554a99c54d5655d372dc2
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,23 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [2.5.19] - 2026-08-06
34
+
35
+ - TAG: [v2.5.19][2.5.19t]
36
+ - COVERAGE: 91.34% -- 5431/5946 lines in 47 files
37
+ - BRANCH COVERAGE: 75.28% -- 2037/2706 branches in 47 files
38
+ - 50.64% documented
39
+
40
+ ### Changed
41
+
42
+ - Track the released kettle-gha-pins client and its cache/tag-resolution contract in the development lockfile and specs.
43
+
44
+ ### Fixed
45
+
46
+ - Add release validation for Markdown reference labels and local heading anchors.
47
+
48
+ - Restore the Discord badge reference in CONTRIBUTING.md.
49
+
33
50
  ## [2.5.18] - 2026-08-03
34
51
 
35
52
  - TAG: [v2.5.18][2.5.18t]
@@ -3242,7 +3259,9 @@ Please file a bug if you notice a violation of semantic versioning.
3242
3259
  - Selecting will run the selected workflow via `act`
3243
3260
  - This may move to its own gem in the future.
3244
3261
 
3245
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.18...HEAD
3262
+ [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.19...HEAD
3263
+ [2.5.19]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.18...v2.5.19
3264
+ [2.5.19t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.19
3246
3265
  [2.5.18]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.17...v2.5.18
3247
3266
  [2.5.18t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v2.5.18
3248
3267
  [2.5.17]: https://github.com/kettle-dev/kettle-dev/compare/v2.5.16...v2.5.17
data/README.md CHANGED
@@ -409,6 +409,8 @@ What it does:
409
409
  - Script: `exe/kettle-release` (run as `kettle-release`)
410
410
  - Purpose: guided release helper that:
411
411
  - Runs `kettle-pre-release` as step 0 on full releases, aborting before release setup if any pre-release gate fails.
412
+ - Validates Markdown reference definitions and local heading anchors across releasable documentation before network checks.
413
+ Markdown fixtures under `spec/` and `test/`, along with `tmp/`, are intentionally excluded.
412
414
  - Skips pre-release checks for any `start_step` greater than `0`, so resumptions begin directly at the requested numbered step.
413
415
  - Runs sanity checks (`bin/setup`, `bin/rake`), confirms version/changelog, optionally updates Appraisals, regenerates docs via `bin/rake yard`, commits “🔖 Prepare release vX.Y.Z”.
414
416
  - Optionally runs your CI locally with `act` before any push:
@@ -1005,7 +1007,7 @@ Thanks for RTFM. ☺️
1005
1007
  [📌gitmoji]: https://gitmoji.dev
1006
1008
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1007
1009
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1008
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-5.788-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1010
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-5.946-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1009
1011
  [🔐security]: https://github.com/kettle-dev/kettle-dev/blob/main/SECURITY.md
1010
1012
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1011
1013
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "uri"
5
+
6
+ module Kettle
7
+ module Dev
8
+ # Validates Markdown reference labels and local link destinations.
9
+ #
10
+ # This is intentionally a small source scanner rather than a second Markdown
11
+ # rendering pipeline. The release gate needs reference integrity and local
12
+ # anchor checks, while the existing Markdown merge/parser dependencies are
13
+ # not runtime dependencies of kettle-dev.
14
+ class MarkdownReferenceValidator
15
+ Issue = Struct.new(:path, :line, :message)
16
+ Report = Struct.new(:file_count, :reference_count, :local_target_count, :issues)
17
+
18
+ FENCE_RE = /\A\s*(`{3,}|~{3,})/.freeze
19
+ IGNORED_PATH_PREFIXES = %w[tmp/ .git/ spec/ test/].freeze
20
+ REFERENCE_DEFINITION_RE = /\A {0,3}\[([^\]]+)\]:\s*(?:<([^>]+)>|(\S+))/
21
+ REFERENCE_USAGE_RE = /!?\[([^\]]*)\]\[([^\]]*)\]/
22
+ INLINE_LINK_RE = /!?\[[^\]]*\]\((<[^>]+>|[^)]*)\)/
23
+ ATX_HEADING_RE = /\A {0,3}\#{1,6}\s+(.+?)\s*\#*\s*\z/
24
+ SETEXT_HEADING_RE = /\A {0,3}(=+|-+)\s*\z/
25
+
26
+ def initialize(root: Dir.pwd, files: nil)
27
+ @root = File.expand_path(root)
28
+ @files = (files || default_files).reject { |path| ignored_path?(path) }
29
+ end
30
+
31
+ def validate!
32
+ documents = @files.each_with_object({}) do |path, result|
33
+ absolute = absolute_path(path)
34
+ result[path] = parse_document(path, absolute) if File.file?(absolute)
35
+ end
36
+ report = build_report(documents)
37
+ return report if report.issues.empty?
38
+
39
+ warn("[kettle-pre-release] Markdown reference validation failed:")
40
+ report.issues.each do |issue|
41
+ warn(" - #{Kettle::Dev.display_path(issue.path)}:#{issue.line}: #{issue.message}")
42
+ end
43
+ raise Kettle::Dev::Error, "Markdown reference validation failed (#{report.issues.size} issue(s))"
44
+ end
45
+
46
+ private
47
+
48
+ def default_files
49
+ if defined?(Kettle::Dev::PreReleaseCLI::Markdown)
50
+ Kettle::Dev::PreReleaseCLI::Markdown.project_markdown_files.reject { |path| ignored_path?(path) }
51
+ else
52
+ Dir.glob(["**/*.md", "**/*.markdown", "**/*.md.example"]).reject { |path| ignored_path?(path) }.sort
53
+ end
54
+ end
55
+
56
+ def ignored_path?(path)
57
+ IGNORED_PATH_PREFIXES.any? { |prefix| path.to_s.start_with?(prefix) }
58
+ end
59
+
60
+ def absolute_path(path)
61
+ File.expand_path(path, @root)
62
+ end
63
+
64
+ def parse_document(path, absolute)
65
+ lines = File.readlines(absolute, chomp: true)
66
+ definitions = {}
67
+ usages = []
68
+ local_targets = []
69
+ headings = []
70
+ in_fence = false
71
+ fence_char = nil
72
+ fence_length = 0
73
+ previous_text = nil
74
+
75
+ lines.each_with_index do |line, index|
76
+ line_number = index + 1
77
+ fence = line.match(FENCE_RE)
78
+ if in_fence
79
+ if fence && fence[1][0] == fence_char && fence[1].length >= fence_length
80
+ in_fence = false
81
+ end
82
+ next
83
+ elsif fence
84
+ in_fence = true
85
+ fence_char = fence[1][0]
86
+ fence_length = fence[1].length
87
+ next
88
+ end
89
+
90
+ definition = line.match(REFERENCE_DEFINITION_RE)
91
+ if definition
92
+ label = normalize_label(definition[1])
93
+ target = definition[2] || definition[3]
94
+ definitions[label] ||= []
95
+ definitions[label] << [line_number, target]
96
+ local_targets << [line_number, target]
97
+ previous_text = nil
98
+ next
99
+ end
100
+
101
+ line.scan(REFERENCE_USAGE_RE) do |text, label|
102
+ resolved_label = label.empty? ? text : label
103
+ usages << [line_number, normalize_label(resolved_label)]
104
+ end
105
+ line.scan(INLINE_LINK_RE) do |destination|
106
+ target = destination[0]
107
+ target = target[1..-2] if target.start_with?("<") && target.end_with?(">")
108
+ target = target.split(/\s+/, 2).first
109
+ local_targets << [line_number, target] unless target.to_s.empty?
110
+ end
111
+
112
+ if line.match?(SETEXT_HEADING_RE) && previous_text
113
+ headings << [line_number - 1, previous_text]
114
+ previous_text = nil
115
+ else
116
+ heading = heading_for(line)
117
+ headings << heading if heading
118
+ previous_text = line.strip unless line.strip.empty?
119
+ end
120
+ end
121
+
122
+ {
123
+ path: path,
124
+ definitions: definitions,
125
+ usages: usages,
126
+ local_targets: local_targets,
127
+ headings: heading_slugs(headings)
128
+ }
129
+ rescue Errno::EACCES, Errno::ENOENT => error
130
+ {
131
+ path: path,
132
+ definitions: {},
133
+ usages: [],
134
+ local_targets: [],
135
+ headings: [],
136
+ read_error: error
137
+ }
138
+ end
139
+
140
+ def build_report(documents)
141
+ issues = []
142
+ reference_count = 0
143
+ local_target_count = 0
144
+ heading_cache = {}
145
+
146
+ documents.each_value do |document|
147
+ if document[:read_error]
148
+ issues << Issue.new(document[:path], 1, "could not read Markdown file: #{document[:read_error].message}")
149
+ next
150
+ end
151
+
152
+ document[:definitions].each do |label, definitions|
153
+ if definitions.length > 1
154
+ definitions.drop(1).each do |line_number, _target|
155
+ issues << Issue.new(document[:path], line_number, "duplicate reference definition #{label.inspect}")
156
+ end
157
+ end
158
+ end
159
+
160
+ document[:usages].each do |line_number, label|
161
+ next if label.include?("<") || label.include?(">")
162
+
163
+ reference_count += 1
164
+ next if document[:definitions].key?(label)
165
+
166
+ issues << Issue.new(document[:path], line_number, "undefined Markdown reference #{label.inspect}")
167
+ end
168
+
169
+ document[:local_targets].each do |line_number, target|
170
+ destination = local_destination(target)
171
+ next unless destination
172
+
173
+ local_target_count += 1
174
+ target_path, fragment = destination
175
+ target_document = target_path.empty? ? document : documents_for_path(documents, document[:path], target_path)
176
+ if target_document.nil?
177
+ issues << Issue.new(document[:path], line_number, "local Markdown target does not exist: #{target_path.inspect}")
178
+ next
179
+ end
180
+ next if fragment.empty?
181
+
182
+ slugs = heading_cache[target_document[:path]] ||= target_document[:headings]
183
+ next if slugs.include?(fragment)
184
+
185
+ issues << Issue.new(document[:path], line_number, "local heading target does not exist: ##{fragment}")
186
+ end
187
+ end
188
+
189
+ Report.new(documents.size, reference_count, local_target_count, issues)
190
+ end
191
+
192
+ def documents_for_path(documents, source_path, target_path)
193
+ candidate = File.expand_path(target_path, File.dirname(absolute_path(source_path)))
194
+ documents.values.find { |document| absolute_path(document[:path]) == candidate }
195
+ end
196
+
197
+ def local_destination(target)
198
+ value = target.to_s.strip
199
+ return nil if value.empty? || value.start_with?("//")
200
+
201
+ uri = URI.parse(value)
202
+ return nil if uri.scheme || uri.host
203
+ path = unescape(uri.path.to_s)
204
+ fragment = unescape(uri.fragment.to_s).downcase
205
+ return nil if fragment.empty?
206
+ return nil unless path.empty? || markdown_path?(path)
207
+
208
+ [path, fragment]
209
+ rescue URI::InvalidURIError
210
+ nil
211
+ end
212
+
213
+ def unescape(value)
214
+ parser = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
215
+ parser.unescape(value)
216
+ end
217
+
218
+ def markdown_path?(path)
219
+ path.downcase.end_with?(".md", ".markdown", ".md.example")
220
+ end
221
+
222
+ def normalize_label(label)
223
+ label.to_s.downcase.gsub(/\s+/, " ").strip
224
+ end
225
+
226
+ def heading_for(line)
227
+ match = line.match(ATX_HEADING_RE)
228
+ match ? [line[/\A\s*/].to_s.length + 1, match[1]] : nil
229
+ end
230
+
231
+ def heading_slugs(headings)
232
+ counts = Hash.new(0)
233
+ headings.each_with_object(Set.new) do |(_line, text), slugs|
234
+ slug = slugify(text)
235
+ next if slug.empty?
236
+
237
+ suffix = counts[slug]
238
+ counts[slug] += 1
239
+ slugs.add(suffix.zero? ? slug : "#{slug}-#{suffix}")
240
+ end
241
+ end
242
+
243
+ def slugify(text)
244
+ text.to_s.gsub(/<[^>]+>/, "").downcase
245
+ .gsub(/[*_~`]/, "")
246
+ .gsub(/[^\p{Alnum}\s-]/u, "")
247
+ .gsub(/\s+/, "-")
248
+ .squeeze("-")
249
+ .sub(/\A-/, "")
250
+ .sub(/-\z/, "")
251
+ end
252
+ end
253
+ end
254
+ end
@@ -11,6 +11,7 @@ require "net/http"
11
11
  require "openssl"
12
12
  require "time"
13
13
  require_relative "cache_progress"
14
+ require_relative "markdown_reference_validator"
14
15
  require "kettle/ndjson"
15
16
  begin
16
17
  require "addressable/uri"
@@ -24,7 +25,8 @@ module Kettle
24
25
  # Checks:
25
26
  # 1) Ensure GitHub Actions workflow actions are pinned to current SHAs.
26
27
  # 2) Normalize Markdown image URLs using Addressable normalization.
27
- # 3) Validate Markdown image links resolve via cached HTTP(S) HEAD/GET.
28
+ # 3) Validate Markdown references and local heading targets.
29
+ # 4) Validate Markdown image links resolve via cached HTTP(S) HEAD/GET.
28
30
  #
29
31
  # Usage: Kettle::Dev::PreReleaseCLI.new(check_num: 1).run
30
32
  class PreReleaseCLI
@@ -288,6 +290,7 @@ module Kettle
288
290
  checks = []
289
291
  checks << method(:check_github_actions_sha_pins!)
290
292
  checks << method(:check_markdown_uri_normalization!)
293
+ checks << method(:check_markdown_references!)
291
294
  checks << method(:check_markdown_images_http!)
292
295
 
293
296
  start = @check_num
@@ -376,10 +379,30 @@ module Kettle
376
379
  normalized.to_s
377
380
  end
378
381
 
379
- # Check 3: Validate Markdown image links by cached HTTP HEAD/GET.
382
+ # Check 3: Validate Markdown references and local heading targets.
383
+ # @return [void]
384
+ def check_markdown_references!
385
+ puts "[kettle-pre-release] Check 3: Validate Markdown references and local heading targets"
386
+ report = MarkdownReferenceValidator.new(
387
+ root: Dir.pwd,
388
+ files: Markdown.project_markdown_files
389
+ ).validate!
390
+ puts "[kettle-pre-release] Markdown references: #{report.reference_count} references, #{report.local_target_count} local targets, #{report.file_count} files."
391
+ emit_pre_release_event(
392
+ action: "markdown_references",
393
+ status: "ok",
394
+ files: report.file_count,
395
+ references: report.reference_count,
396
+ local_targets: report.local_target_count,
397
+ failures: 0
398
+ )
399
+ nil
400
+ end
401
+
402
+ # Check 4: Validate Markdown image links by cached HTTP HEAD/GET.
380
403
  # @return [void]
381
404
  def check_markdown_images_http!
382
- puts "[kettle-pre-release] Check 3: Validate Markdown image links (cached HTTP HEAD, with GET fallback)"
405
+ puts "[kettle-pre-release] Check 4: Validate Markdown image links (cached HTTP HEAD, with GET fallback)"
383
406
  urls = Markdown.extract_image_urls_from_files
384
407
  puts "[kettle-pre-release] Found #{urls.size} unique image URL(s)."
385
408
  skipped = []
@@ -5,7 +5,7 @@ module Kettle
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = "2.5.18"
8
+ VERSION = "2.5.19"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data/lib/kettle/dev.rb CHANGED
@@ -44,6 +44,7 @@ module Kettle
44
44
  autoload :InputAdapter, "kettle/dev/input_adapter"
45
45
  autoload :InteractiveReleaseCommand, "kettle/dev/interactive_release_command"
46
46
  autoload :LockfileReset, "kettle/dev/lockfile_reset"
47
+ autoload :MarkdownReferenceValidator, "kettle/dev/markdown_reference_validator"
47
48
  autoload :ReadmeBackers, "kettle/dev/readme_backers"
48
49
  autoload :OpenCollectiveConfig, "kettle/dev/open_collective_config"
49
50
  autoload :ReleaseNotifier, "kettle/dev/release_notifier"
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: 2.5.18
4
+ version: 2.5.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -370,6 +370,7 @@ files:
370
370
  - lib/kettle/dev/input_adapter.rb
371
371
  - lib/kettle/dev/interactive_release_command.rb
372
372
  - lib/kettle/dev/lockfile_reset.rb
373
+ - lib/kettle/dev/markdown_reference_validator.rb
373
374
  - lib/kettle/dev/open_collective_config.rb
374
375
  - lib/kettle/dev/pre_release_cli.rb
375
376
  - lib/kettle/dev/rakelib/appraisal.rake
@@ -401,10 +402,10 @@ licenses:
401
402
  - AGPL-3.0-only
402
403
  metadata:
403
404
  homepage_uri: https://kettle-dev.galtzo.com
404
- source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v2.5.18
405
- changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.5.18/CHANGELOG.md
405
+ source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v2.5.19
406
+ changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v2.5.19/CHANGELOG.md
406
407
  bug_tracker_uri: https://github.com/kettle-dev/kettle-dev/issues
407
- documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.5.18
408
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/2.5.19
408
409
  funding_uri: https://github.com/sponsors/pboling
409
410
  wiki_uri: https://github.com/kettle-dev/kettle-dev/wiki
410
411
  news_uri: https://www.railsbling.com/tags/kettle-dev
metadata.gz.sig CHANGED
Binary file