ratatui_ruby-devtools 0.2.1 → 0.4.0

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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +69 -7
  5. data/README.md +62 -21
  6. data/Rakefile +1 -1
  7. data/bin/setup +20 -6
  8. data/bin/setup.ps1 +74 -0
  9. data/doc/contributors/documentation_style.md +3 -4
  10. data/exe/scaffold +8 -342
  11. data/lib/ratatui_ruby/devtools/scaffold/scaffold_config.rb +67 -0
  12. data/lib/ratatui_ruby/devtools/scaffold/template_renderer.rb +282 -0
  13. data/lib/ratatui_ruby/devtools/task_scope.rb +24 -0
  14. data/lib/ratatui_ruby/devtools/tasks/autodoc_tasks.rb +31 -0
  15. data/lib/ratatui_ruby/devtools/tasks/bump/bump_workflow.rb +71 -0
  16. data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +60 -3
  17. data/lib/ratatui_ruby/devtools/tasks/bump/github_project.rb +26 -0
  18. data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +3 -3
  19. data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +39 -24
  20. data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +9 -17
  21. data/lib/ratatui_ruby/devtools/tasks/bump/no_crate.rb +19 -0
  22. data/lib/ratatui_ruby/devtools/tasks/bump/no_links.rb +22 -0
  23. data/lib/ratatui_ruby/devtools/tasks/bump/patch_release.rb +25 -0
  24. data/lib/ratatui_ruby/devtools/tasks/bump/release_branch.rb +17 -0
  25. data/lib/ratatui_ruby/devtools/tasks/bump/release_from_trunk.rb +48 -0
  26. data/lib/ratatui_ruby/devtools/tasks/bump/repository.rb +143 -0
  27. data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +65 -59
  28. data/lib/ratatui_ruby/devtools/tasks/bump/rust_crate.rb +42 -0
  29. data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +127 -10
  30. data/lib/ratatui_ruby/devtools/tasks/bump/source_hut_project.rb +26 -0
  31. data/lib/ratatui_ruby/devtools/tasks/bump/unbranched_release.rb +20 -0
  32. data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +19 -1
  33. data/lib/ratatui_ruby/devtools/tasks/bump_tasks.rb +47 -0
  34. data/lib/ratatui_ruby/devtools/tasks/cargo_tasks.rb +57 -0
  35. data/lib/ratatui_ruby/devtools/tasks/doc/documentation.rb +59 -0
  36. data/lib/ratatui_ruby/devtools/tasks/doc/link/file_url.rb +30 -0
  37. data/lib/ratatui_ruby/devtools/tasks/doc/link/relative_path.rb +61 -0
  38. data/lib/ratatui_ruby/devtools/tasks/doc/link/web_url.rb +55 -0
  39. data/lib/ratatui_ruby/devtools/tasks/doc/link.rb +52 -0
  40. data/lib/ratatui_ruby/devtools/tasks/doc/link_audit.rb +116 -0
  41. data/lib/ratatui_ruby/devtools/tasks/doc/problem.rb +40 -0
  42. data/lib/ratatui_ruby/devtools/tasks/doc/source_file.rb +93 -0
  43. data/lib/ratatui_ruby/devtools/tasks/doc_tasks.rb +937 -0
  44. data/lib/ratatui_ruby/devtools/tasks/license/blame.rb +78 -0
  45. data/lib/ratatui_ruby/devtools/tasks/license/changed_files.rb +34 -0
  46. data/lib/ratatui_ruby/devtools/tasks/license/contribution.rb +12 -0
  47. data/lib/ratatui_ruby/devtools/tasks/license/copyright.rb +47 -0
  48. data/lib/ratatui_ruby/devtools/tasks/license/directory_licensing.rb +42 -0
  49. data/lib/ratatui_ruby/devtools/tasks/license/existing_header.rb +21 -0
  50. data/lib/ratatui_ruby/devtools/tasks/license/header_revision.rb +45 -0
  51. data/lib/ratatui_ruby/devtools/tasks/license/licensed_file.rb +39 -0
  52. data/lib/ratatui_ruby/devtools/tasks/license/licensing.rb +35 -0
  53. data/lib/ratatui_ruby/devtools/tasks/license/markdown_document.rb +23 -0
  54. data/lib/ratatui_ruby/devtools/tasks/license/markdown_files.rb +31 -0
  55. data/lib/ratatui_ruby/devtools/tasks/license/markdown_header_syntax.rb +104 -0
  56. data/lib/ratatui_ruby/devtools/tasks/license/markdown_snippet_syntax.rb +159 -0
  57. data/lib/ratatui_ruby/devtools/tasks/license/missing_header.rb +18 -0
  58. data/lib/ratatui_ruby/devtools/tasks/license/rbs_files.rb +27 -0
  59. data/lib/ratatui_ruby/devtools/tasks/license/rbs_header_syntax.rb +33 -0
  60. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_comment.rb +51 -0
  61. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_examples.rb +41 -0
  62. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_snippet_syntax.rb +119 -0
  63. data/lib/ratatui_ruby/devtools/tasks/license/renewal.rb +17 -0
  64. data/lib/ratatui_ruby/devtools/tasks/license/reuse_annotated_file.rb +12 -0
  65. data/lib/ratatui_ruby/devtools/tasks/license/reuse_tool.rb +34 -0
  66. data/lib/ratatui_ruby/devtools/tasks/license/ruby_comment_header.rb +57 -0
  67. data/lib/ratatui_ruby/devtools/tasks/license/ruby_files.rb +36 -0
  68. data/lib/ratatui_ruby/devtools/tasks/license/ruby_header_syntax.rb +47 -0
  69. data/lib/ratatui_ruby/devtools/tasks/license/rust_files.rb +27 -0
  70. data/lib/ratatui_ruby/devtools/tasks/license/rust_header_syntax.rb +68 -0
  71. data/lib/ratatui_ruby/devtools/tasks/license/snippet.rb +44 -0
  72. data/lib/ratatui_ruby/devtools/tasks/license/snippet_file.rb +29 -0
  73. data/lib/ratatui_ruby/devtools/tasks/license/snippet_opening.rb +39 -0
  74. data/lib/ratatui_ruby/devtools/tasks/license/snippet_revision.rb +75 -0
  75. data/lib/ratatui_ruby/devtools/tasks/license/spdx_header.rb +42 -0
  76. data/lib/ratatui_ruby/devtools/tasks/license/unknown_file.rb +12 -0
  77. data/lib/ratatui_ruby/devtools/tasks/license/unlicensed_files.rb +69 -0
  78. data/lib/ratatui_ruby/devtools/tasks/license/wrapped_snippet.rb +30 -0
  79. data/lib/ratatui_ruby/devtools/tasks/license_tasks.rb +71 -0
  80. data/lib/ratatui_ruby/devtools/tasks/lint/gem_lockfile.rb +29 -0
  81. data/lib/ratatui_ruby/devtools/tasks/lint/supported_rubies.rb +18 -0
  82. data/lib/ratatui_ruby/devtools/tasks/lint_tasks.rb +100 -0
  83. data/lib/ratatui_ruby/devtools/tasks/release/platform_gem.rb +21 -12
  84. data/lib/ratatui_ruby/devtools/tasks/release/versioned_binary.rb +1 -1
  85. data/lib/ratatui_ruby/devtools/tasks/release_tasks.rb +80 -0
  86. data/lib/ratatui_ruby/devtools/tasks/reuse_tasks.rb +39 -0
  87. data/lib/ratatui_ruby/devtools/tasks/{test.rake → test_tasks.rb} +13 -4
  88. data/lib/ratatui_ruby/devtools/tasks/website/hosted_source_tree.rb +24 -0
  89. data/lib/ratatui_ruby/devtools/tasks/website/index_page.rb +1 -4
  90. data/lib/ratatui_ruby/devtools/tasks/website/source_tree.rb +63 -0
  91. data/lib/ratatui_ruby/devtools/tasks/website/versioned_documentation.rb +14 -13
  92. data/lib/ratatui_ruby/devtools/tasks/website_tasks.rb +49 -0
  93. data/lib/ratatui_ruby/devtools/templates/.github/workflows/build-gems.yml.erb +15 -9
  94. data/lib/ratatui_ruby/devtools/templates/.github/workflows/ci.yml.erb +2 -2
  95. data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +2 -1
  96. data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +1 -1
  97. data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +1 -1
  98. data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +3 -1
  99. data/lib/ratatui_ruby/devtools/templates/CONTRIBUTING.md.erb +3 -3
  100. data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +2 -3
  101. data/lib/ratatui_ruby/devtools/templates/README.md.erb +7 -7
  102. data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +1 -1
  103. data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +5 -1
  104. data/lib/ratatui_ruby/devtools/templates/bin/setup.ps1.erb +5 -1
  105. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +2 -2
  106. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +3 -3
  107. data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +4 -4
  108. data/lib/ratatui_ruby/devtools/templates/ext/Cargo.toml.erb +1 -1
  109. data/lib/ratatui_ruby/devtools/templates/gemspec.erb +1 -1
  110. data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +3 -3
  111. data/lib/ratatui_ruby/devtools/version.rb +1 -1
  112. data/lib/ratatui_ruby/devtools.rb +54 -71
  113. data/mise.toml +2 -2
  114. data/sig/ratatui_ruby/devtools.rbs +16 -1
  115. metadata +93 -34
  116. data/.builds/ruby-4.0.yml +0 -38
  117. data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +0 -21
  118. data/lib/ratatui_ruby/devtools/tasks/bump.rake +0 -80
  119. data/lib/ratatui_ruby/devtools/tasks/cargo.rake +0 -47
  120. data/lib/ratatui_ruby/devtools/tasks/doc.rake +0 -887
  121. data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +0 -276
  122. data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +0 -236
  123. data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +0 -143
  124. data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +0 -353
  125. data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +0 -186
  126. data/lib/ratatui_ruby/devtools/tasks/license.rake +0 -91
  127. data/lib/ratatui_ruby/devtools/tasks/lint.rake +0 -80
  128. data/lib/ratatui_ruby/devtools/tasks/release.rake +0 -87
  129. data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +0 -7
  130. data/lib/ratatui_ruby/devtools/tasks/reuse.rake +0 -104
  131. data/lib/ratatui_ruby/devtools/tasks/website.rake +0 -37
  132. data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +0 -47
  133. data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +0 -45
  134. data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +0 -8
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "copyright"
9
+ require_relative "renewal"
10
+ require_relative "spdx_header"
11
+
12
+ # The tags that open a snippet already wrapped, from their first to their last
13
+ # line counting from 0, with the notices they hold.
14
+ class SnippetOpening < Data.define(:first, :last, :header) # :nodoc:
15
+ # REUSE-IgnoreStart
16
+ COPYRIGHT = /SPDX-(?:Snippet|File)CopyrightText:\s*(\d{4})\s+(.+)$/
17
+ LICENSE = /SPDX-License-Identifier:\s*(\S+)/
18
+ # REUSE-IgnoreEnd
19
+
20
+ def self.in(lines, first, last)
21
+ tags = lines[first..last]
22
+ copyrights = tags.grep(COPYRIGHT) { Copyright.new(year: $1.to_i, holder: $2.strip) }
23
+ license = tags.grep(LICENSE) { $1 }.last
24
+ new(first:, last:, header: SpdxHeader.new(copyrights:, license:))
25
+ end
26
+
27
+ def span
28
+ first..last
29
+ end
30
+
31
+ def copyrights
32
+ header.copyrights
33
+ end
34
+
35
+ def renewal(contributions, license)
36
+ renewal = header.renewal(contributions, license)
37
+ Renewal.new(value: with(header: renewal.value), notices: renewal.notices)
38
+ end
39
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "blame"
9
+ require_relative "copyright"
10
+
11
+ # One file's snippets brought up to date: each bare snippet asks to be
12
+ # wrapped, and each wrapped one whose notices have moved on asks to be
13
+ # reopened.
14
+ class SnippetRevision # :nodoc:
15
+ def initialize(path:, text:, syntax:, author:)
16
+ @path = path
17
+ @original = text
18
+ @syntax = syntax
19
+ @author = author
20
+ @lines = syntax.cleaned(text.lines)
21
+ @snippets = syntax.snippets_in(@lines)
22
+ @notices = []
23
+ # Blame reads the cleaned lines the snippets were found in, not the file
24
+ # on disk.
25
+ @blame = Blame.new(path:, contents: @lines.join)
26
+ end
27
+
28
+ # Revises from the last snippet back, so the earlier ones stay on the lines
29
+ # blame found them on.
30
+ def run
31
+ @snippets.reverse_each { |snippet| snippet.revise(self) }
32
+ self
33
+ end
34
+
35
+ def text
36
+ @lines.join
37
+ end
38
+
39
+ def changed?
40
+ text != @original
41
+ end
42
+
43
+ # What the revision did, for whoever ran it.
44
+ def messages
45
+ return [] unless changed?
46
+
47
+ wrapped = @snippets.count(&:bare?)
48
+ summary = if wrapped.zero?
49
+ "Updated: #{@path}"
50
+ else
51
+ "Updated: #{@path} (#{wrapped} code block(s))"
52
+ end
53
+ [*@notices.map { |notice| " #{notice}" }, summary]
54
+ end
55
+
56
+ def contributions(snippet)
57
+ @blame.contributions([snippet.blame_range], uncommitted_by: @author)
58
+ end
59
+
60
+ def license
61
+ @syntax.license
62
+ end
63
+
64
+ def wrap(snippet)
65
+ copyrights = contributions(snippet).map { |contribution| Copyright.of(contribution) }.sort_by(&:holder)
66
+ @lines.insert(snippet.last + 1, *@syntax.closing(snippet))
67
+ @lines.insert(snippet.first, *@syntax.opening(snippet, copyrights))
68
+ end
69
+
70
+ def reopen(snippet, renewal)
71
+ opening = renewal.value
72
+ @lines[opening.span] = @syntax.opening(snippet, opening.copyrights)
73
+ @notices.concat(renewal.notices)
74
+ end
75
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "copyright"
9
+ require_relative "renewal"
10
+
11
+ class SpdxHeader < Data.define(:copyrights, :license) # :nodoc:
12
+ def self.of(author, contributions, license)
13
+ copyrights = if contributions.empty?
14
+ [Copyright.yours(author)]
15
+ else
16
+ contributions.map { |contribution| Copyright.of(contribution) }.sort_by(&:holder)
17
+ end
18
+ new(copyrights:, license:)
19
+ end
20
+
21
+ def renewal(contributions, license)
22
+ renewals = copyrights.map { |copyright| copyright.renewal(contributions) }
23
+ renewed_copyrights = renewals.map(&:value)
24
+ notices = renewals.flat_map(&:notices)
25
+ contributions.each do |contribution|
26
+ next if renewed_copyrights.any? { |copyright| copyright.held_by?(contribution.author) }
27
+
28
+ notices << "Adding #{contribution.author.name}'s copyright"
29
+ renewed_copyrights << Copyright.of(contribution)
30
+ end
31
+ notices << "Fixing license: #{self.license} -> #{license}" if self.license != license
32
+
33
+ Renewal.new(value: with(copyrights: renewed_copyrights, license:), notices:)
34
+ end
35
+
36
+ def commented(prefix)
37
+ # REUSE-IgnoreStart
38
+ copyrights.map { |copyright| "#{prefix} SPDX-FileCopyrightText: #{copyright}\n" }.join +
39
+ "#{prefix} SPDX-License-Identifier: #{license}\n"
40
+ # REUSE-IgnoreEnd
41
+ end
42
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ class UnknownFile < Data.define(:path) # :nodoc:
9
+ def annotate
10
+ ["Skipping #{path} (unknown extension: .#{File.extname(path).delete_prefix('.')})"]
11
+ end
12
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "licensed_file"
9
+ require_relative "licensing"
10
+ require_relative "markdown_header_syntax"
11
+ require_relative "rbs_header_syntax"
12
+ require_relative "reuse_annotated_file"
13
+ require_relative "ruby_header_syntax"
14
+ require_relative "rust_header_syntax"
15
+ require_relative "unknown_file"
16
+
17
+ # The files the REUSE Tool finds without headers. The license tasks know each
18
+ # language's header and who to credit, so the tool annotates only the rest.
19
+ class UnlicensedFiles # :nodoc:
20
+ CODE_EXTENSIONS = %w[rb rs rake gemspec rbs toml yml yaml json lock].freeze
21
+ DOC_EXTENSIONS = %w[md txt].freeze
22
+ DOC_LICENSE = "CC-BY-SA-4.0"
23
+
24
+ def initialize(tool, repository, overrides, licensing)
25
+ @tool = tool
26
+ @repository = repository
27
+ @overrides = overrides
28
+ @licensing = licensing
29
+ @syntaxes = [
30
+ MarkdownHeaderSyntax.new,
31
+ RubyHeaderSyntax.new(licensing),
32
+ RbsHeaderSyntax.new(licensing),
33
+ RustHeaderSyntax.new(licensing),
34
+ ]
35
+ end
36
+
37
+ # Licenses what the tool found and says what it did.
38
+ def license
39
+ paths = @tool.unlicensed_paths
40
+ return ["All files have REUSE headers!"] if paths.empty?
41
+
42
+ paths.flat_map { |path| file_at(path).annotate.map { |message| " #{message}" } }
43
+ end
44
+
45
+ private def file_at(path)
46
+ syntax = @syntaxes.find { |candidate| candidate.covers?(path) }
47
+ return LicensedFile.new(path:, syntax:, author:) if syntax
48
+
49
+ license = license_for(path)
50
+ return UnknownFile.new(path:) unless license
51
+
52
+ ReuseAnnotatedFile.new(path:, license:, copyright:, tool: @tool)
53
+ end
54
+
55
+ private def license_for(path)
56
+ case File.extname(path).delete_prefix(".")
57
+ when *CODE_EXTENSIONS then @overrides.fetch("REUSE_CODE_LICENSE") { @licensing.license_for(path) }
58
+ when *DOC_EXTENSIONS then @overrides.fetch("REUSE_DOC_LICENSE", DOC_LICENSE)
59
+ end
60
+ end
61
+
62
+ private def copyright
63
+ @overrides.fetch("REUSE_COPYRIGHT") { author.to_s }
64
+ end
65
+
66
+ private def author
67
+ @author ||= @repository.author
68
+ end
69
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # A Snippet that already has its wrapper, opened by its SnippetOpening.
9
+ class WrappedSnippet < Data.define(:snippet, :opening) # :nodoc:
10
+ def first
11
+ snippet.first
12
+ end
13
+
14
+ def covers?(other)
15
+ other.first.between?(snippet.first, snippet.last)
16
+ end
17
+
18
+ def blame_range
19
+ snippet.blame_range
20
+ end
21
+
22
+ def bare?
23
+ false
24
+ end
25
+
26
+ def revise(revision)
27
+ renewal = opening.renewal(revision.contributions(self), revision.license)
28
+ revision.reopen(snippet, renewal) if renewal.changed?
29
+ end
30
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "rake/tasklib"
9
+
10
+ require_relative "license/changed_files"
11
+ require_relative "license/licensed_file"
12
+ require_relative "license/markdown_header_syntax"
13
+ require_relative "license/markdown_snippet_syntax"
14
+ require_relative "license/rbs_header_syntax"
15
+ require_relative "license/rdoc_snippet_syntax"
16
+ require_relative "license/ruby_header_syntax"
17
+ require_relative "license/rust_header_syntax"
18
+ require_relative "license/snippet_file"
19
+
20
+ # The tasks that write license headers and snippet wrappers.
21
+ class LicenseTasks < Rake::TaskLib # :nodoc:
22
+ def initialize(licensing)
23
+ super()
24
+ @licensing = licensing
25
+ define
26
+ end
27
+
28
+ private def define
29
+ namespace :license do
30
+ namespace :headers do
31
+ {
32
+ md: ["Ensure markdown files have correct CC-BY-SA-4.0 headers", MarkdownHeaderSyntax.new],
33
+ rb: ["Ensure Ruby files have correct SPDX headers", RubyHeaderSyntax.new(@licensing)],
34
+ rbs: ["Ensure RBS files have correct SPDX headers", RbsHeaderSyntax.new(@licensing)],
35
+ rs: ["Ensure Rust files have correct SPDX headers", RustHeaderSyntax.new(@licensing)],
36
+ }.each do |name, (description, syntax)|
37
+ desc description
38
+ task name, [:files] do |_t, args|
39
+ LicensedFile.tracked(args[:files].to_s.split, syntax).flat_map(&:annotate).each { |message| puts message }
40
+ end
41
+ end
42
+
43
+ desc "Ensure all files have correct license headers"
44
+ task all: %w[md rb rbs rs]
45
+ end
46
+
47
+ namespace :snippets do
48
+ {
49
+ md: ["Add MIT-0 SPDX snippet headers to markdown fenced code blocks", MarkdownSnippetSyntax, ""],
50
+ rdoc: ["Add MIT-0 SPDX snippet headers to RDoc code examples in Ruby files", RdocSnippetSyntax, "lib/"],
51
+ }.each do |name, (description, syntax, default)|
52
+ desc description
53
+ task name, [:files] do |_t, args|
54
+ SnippetFile.tracked((args[:files] || default).split, syntax.new).flat_map(&:annotate).each { |message| puts message }
55
+ end
56
+ end
57
+
58
+ desc "Add MIT-0 SPDX snippet headers to all code examples"
59
+ task all: %w[md rdoc]
60
+ end
61
+
62
+ desc "Run all license tasks (headers + snippets)"
63
+ task all: ["headers:all", "snippets:all"]
64
+
65
+ desc "Run license tasks on changed files only (staged + unstaged)"
66
+ task :new do
67
+ ChangedFiles.new(Repository.new, @licensing).license.each { |message| puts message }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # The gems Bundler locked for this project.
9
+ #
10
+ # CI installs the lockfile on every supported Ruby, but Bundler only checks
11
+ # gems against the Ruby that resolves it. A lockfile resolved on the newest
12
+ # Ruby can hold gems the oldest one can't install.
13
+ class GemLockfile < Data.define(:specs) # :nodoc:
14
+ def gems_too_new_for(ruby)
15
+ specs.reject { |spec| spec.required_ruby_version.satisfied_by?(ruby) }
16
+ end
17
+
18
+ def verify(ruby)
19
+ too_new = gems_too_new_for(ruby)
20
+ return if too_new.empty?
21
+
22
+ abort <<~MESSAGE
23
+ These locked gems need a newer Ruby than #{ruby}, the oldest the gemspec supports:
24
+ #{too_new.map { |spec| " #{spec.full_name} (Ruby #{spec.required_ruby_version})" }.join("\n")}
25
+ Resolve them with that Ruby instead:
26
+ mise x ruby@#{ruby} -- bundle lock --update #{too_new.map(&:name).join(' ')}
27
+ MESSAGE
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # The Ruby versions a gemspec supports.
9
+ class SupportedRubies < Data.define(:requirement) # :nodoc:
10
+ def self.of(spec)
11
+ new(requirement: spec.required_ruby_version)
12
+ end
13
+
14
+ def oldest
15
+ _, version = requirement.requirements.find { |operator, _| operator == ">=" }
16
+ version
17
+ end
18
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "rubocop/rake_task"
9
+ require "rubycritic/rake_task"
10
+ require "inch/rake"
11
+ require "rake/tasklib"
12
+
13
+ # The tasks that lint a gem.
14
+ class LintTasks < Rake::TaskLib # :nodoc:
15
+ def initialize(gem)
16
+ super()
17
+ @gem = gem
18
+ define
19
+ end
20
+
21
+ private def define
22
+ RuboCop::RakeTask.new
23
+
24
+ # Run rubycritic in a shell to prevent it from exiting the rake process
25
+ task :rubycritic do
26
+ paths = []
27
+ paths << "exe" if Dir.exist?("exe")
28
+ paths << "lib" if Dir.exist?("lib")
29
+ paths << "sig" if Dir.exist?("sig")
30
+ sh "bundle exec rubycritic --no-browser #{paths.join(' ')}" unless paths.empty?
31
+ end
32
+
33
+ Inch::Rake::Suggest.new("doc:suggest", "exe/**/*.rb", "lib/**/*.rb", "sig/**/*.rbs") do |suggest|
34
+ suggest.args << ""
35
+ end
36
+
37
+ namespace :lint do
38
+ task :safe_rdoc_coverage do
39
+ if Rake::Task.task_defined?("rdoc:coverage")
40
+ sh "bundle exec rake rdoc:coverage"
41
+ else
42
+ puts "rdoc:coverage task not defined, skipping"
43
+ end
44
+ end
45
+
46
+ desc "Check that every locked gem installs on the oldest supported Ruby"
47
+ task :lockfile_ruby do
48
+ require_relative "lint/gem_lockfile"
49
+ require_relative "lint/supported_rubies"
50
+
51
+ GemLockfile.new(specs: Bundler.load.specs.to_a).verify(SupportedRubies.of(@gem.spec).oldest)
52
+ end
53
+
54
+ # Build dynamic task lists based on what's available
55
+ docs_tasks = %w[rubycritic]
56
+ docs_tasks.unshift("autodoc") if Rake::Task.task_defined?("autodoc")
57
+ docs_tasks << "safe_rdoc_coverage" if Rake::Task.task_defined?("rdoc:coverage")
58
+
59
+ code_tasks = %w[rubocop rubycritic lockfile_ruby]
60
+ code_tasks += %w[cargo:fmt cargo:clippy cargo:test] if Dir.exist?("ext")
61
+
62
+ task docs: docs_tasks
63
+ task code: code_tasks
64
+ task all: %w[docs code]
65
+
66
+ namespace :fix do
67
+ desc "Auto-fix RuboCop offenses (most aggressive)"
68
+ task :rubocop do
69
+ sh "bundle exec rubocop --autocorrect-all"
70
+ end
71
+
72
+ if Dir.exist?("ext")
73
+ desc "Auto-fix Clippy warnings (most aggressive: --fix --allow-dirty --allow-staged)"
74
+ task :clippy do
75
+ ext_dirs = Dir.glob("ext/*").select { |d| File.directory?(d) && File.exist?("#{d}/Cargo.toml") }
76
+ ext_dirs.each do |dir|
77
+ sh "cd #{dir} && cargo clippy --fix --allow-dirty --allow-staged"
78
+ end
79
+ end
80
+ end
81
+
82
+ # Build dynamic fix:all task
83
+ fix_all_tasks = %w[lint:fix:rubocop]
84
+ fix_all_tasks.insert(1, "lint:fix:clippy") if Dir.exist?("ext")
85
+
86
+ desc "Run all auto-fix tasks"
87
+ task all: fix_all_tasks
88
+ end
89
+ end
90
+
91
+ desc "Run all lint auto-fix tasks"
92
+ task("lint:fix") { Rake::Task["lint:fix:all"].invoke }
93
+
94
+ # Aliases for convenience
95
+ task "rubocop:autocorrect_all" => "lint:fix:rubocop"
96
+
97
+ desc "Run all lint tasks"
98
+ task(:lint) { Rake::Task["lint:all"].invoke }
99
+ end
100
+ end
@@ -17,21 +17,20 @@ require_relative "versioned_binary"
17
17
  # A PlatformGem collects those binaries and packages them into a single
18
18
  # installable gem that serves all supported Ruby versions.
19
19
  class PlatformGem
20
- # Loads the gemspec and scans for compiled binaries.
21
- def initialize(
22
- spec = Gem::Specification.load(RatatuiRuby::Devtools.gemspec_file),
23
- lib_dir = "lib/#{RatatuiRuby::Devtools.gem_name.tr('-', '_')}"
24
- )
25
- @spec = spec
26
- @lib_dir = lib_dir
27
- @binaries = VersionedBinary.scan(lib_dir, RatatuiRuby::Devtools.gem_name)
20
+ # Scans the gem's library for compiled binaries.
21
+ #
22
+ # [gem] The RubyGem to package.
23
+ def initialize(gem)
24
+ @spec = gem.spec
25
+ @lib_dir = "lib/#{gem.name.tr('-', '_')}"
26
+ @binaries = VersionedBinary.scan(@lib_dir, gem.name)
28
27
  end
29
28
 
30
29
  # Packages the platform gem into +pkg/+.
31
30
  def build
32
31
  abort "No versioned binaries found in #{@lib_dir}/*/" if @binaries.empty?
33
32
 
34
- @spec.platform = Gem::Platform.local
33
+ @spec.platform = platform
35
34
  @spec.extensions.clear
36
35
  @spec.files += @binaries.map(&:path)
37
36
 
@@ -56,8 +55,18 @@ class PlatformGem
56
55
  FileUtils.mv(gem_file, "pkg")
57
56
  puts "Built pkg/#{File.basename(gem_file)}"
58
57
  end
59
- end
60
58
 
61
- if $PROGRAM_NAME == __FILE__
62
- PlatformGem.new.build
59
+ # The platform of the packaging Ruby, without a darwin version.
60
+ #
61
+ # RubyGems installs an <tt>arm64-darwin-23</tt> gem only on a Ruby that
62
+ # reports exactly darwin 23. That number names the CI image's macOS, not
63
+ # anything the binary needs. The binary targets macOS 11, the first
64
+ # release on Apple Silicon, so <tt>arm64-darwin</tt> fits every Mac
65
+ # that can run it.
66
+ private def platform
67
+ local = Gem::Platform.local
68
+ return local unless local.os == "darwin"
69
+
70
+ Gem::Platform.new([local.cpu, local.os, nil])
71
+ end
63
72
  end
@@ -11,7 +11,7 @@ require_relative "../bump/sem_ver"
11
11
  class VersionedBinary < Data.define(:path)
12
12
  ##
13
13
  # Returns sorted VersionedBinary instances found under +lib_dir+.
14
- def self.scan(lib_dir, gem_name = RatatuiRuby::Devtools.gem_name)
14
+ def self.scan(lib_dir, gem_name)
15
15
  Dir.glob("#{lib_dir}/*/#{gem_name.tr('-', '_')}.*")
16
16
  .reject { |p| p.end_with?(".rb") }
17
17
  .map { |p| new(path: p) }
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "rake/tasklib"
9
+
10
+ require_relative "bump/repository"
11
+ require_relative "release/platform_gem"
12
+
13
+ # The tasks that release a gem and its native gems.
14
+ class ReleaseTasks < Rake::TaskLib # :nodoc:
15
+ def initialize(gem, repository: Repository.new)
16
+ super()
17
+ @gem = gem
18
+ @repository = repository
19
+ define
20
+ end
21
+
22
+ private def define
23
+ namespace :release do
24
+ desc "Package the precompiled binaries in lib/ into a platform gem"
25
+ task :platform_gem do
26
+ PlatformGem.new(@gem).build
27
+ end
28
+
29
+ desc "Update stable branch to match release and set as default"
30
+ task :update_stable do
31
+ version = @gem.release_version
32
+
33
+ # A release that failed leaves the version file ahead of the tags, and
34
+ # stable would then point at the wrong version.
35
+ latest_tag = @repository.latest_tag
36
+ if latest_tag != version.to_tag
37
+ abort "Fatal: Version mismatch! '#{@gem.version_file}' says #{version.to_tag}, " \
38
+ "but the latest git tag is #{latest_tag}."
39
+ end
40
+
41
+ stable = @gem.stable_version(@repository)
42
+ if stable && version.to_gem_version <= stable
43
+ puts "Skipping stable update: #{version} is not newer than current stable #{stable}"
44
+ next
45
+ end
46
+
47
+ puts "Updating stable branch to point to #{version.to_tag}..."
48
+ @repository.move_stable(@repository.released_commit(version.to_tag))
49
+ end
50
+ end
51
+
52
+ if Rake::Task.task_defined?("release")
53
+ release = Rake::Task["release"]
54
+
55
+ # Bundler tags and publishes in the release task's prerequisites, so a
56
+ # version its tag can't name has to stop the release before they run.
57
+ task "release:check_version" do
58
+ @gem.release_version
59
+ end
60
+ release.prerequisites.unshift("release:check_version")
61
+
62
+ release.enhance do
63
+ version = @gem.release_version
64
+
65
+ # Bundler tags the release v plus the version as RubyGems writes it, so
66
+ # a prerelease gets a tag like v1.0.0.pre.beta.1 that isn't SemVer.
67
+ normalized_tag = "v#{version.to_gem_version}"
68
+ if normalized_tag != version.to_tag
69
+ puts "Replacing normalized tag #{normalized_tag} with semver tag #{version.to_tag}..."
70
+ @repository.delete_tag(normalized_tag)
71
+ @repository.tag_head(version.to_tag)
72
+ end
73
+
74
+ @gem.crate.release_native_gems(version: version.to_s, sha: @repository.released_commit(version.to_tag))
75
+
76
+ Rake::Task["release:update_stable"].invoke
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "rake/tasklib"
9
+
10
+ require_relative "bump/repository"
11
+ require_relative "license/reuse_tool"
12
+ require_relative "license/unlicensed_files"
13
+
14
+ # The tasks that check and fix REUSE compliance.
15
+ class ReuseTasks < Rake::TaskLib # :nodoc:
16
+ def initialize(licensing)
17
+ super()
18
+ @licensing = licensing
19
+ define
20
+ end
21
+
22
+ private def define
23
+ namespace :reuse do
24
+ desc "Run the REUSE Tool to confirm REUSE compliance"
25
+ task :lint do
26
+ sh "reuse lint"
27
+ end
28
+
29
+ desc "Add SPDX headers to files missing them (per AGENTS.md standards)"
30
+ task :fix do
31
+ puts "Checking for files missing REUSE headers..."
32
+ overrides = ENV.slice("REUSE_COPYRIGHT", "REUSE_CODE_LICENSE", "REUSE_DOC_LICENSE")
33
+ UnlicensedFiles.new(ReuseTool.new, Repository.new, overrides, @licensing).license.each { |message| puts message }
34
+ end
35
+ end
36
+
37
+ task(:reuse) { Rake::Task["reuse:lint"].invoke }
38
+ end
39
+ end