ratatui_ruby-devtools 0.2.0 → 0.3.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 (135) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +63 -6
  5. data/README.md +49 -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/site_tasks/build.rake +10 -15
  14. data/lib/ratatui_ruby/devtools/task_scope.rb +24 -0
  15. data/lib/ratatui_ruby/devtools/tasks/autodoc_tasks.rb +31 -0
  16. data/lib/ratatui_ruby/devtools/tasks/bump/bump_workflow.rb +71 -0
  17. data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +60 -3
  18. data/lib/ratatui_ruby/devtools/tasks/bump/github_project.rb +26 -0
  19. data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +3 -3
  20. data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +39 -24
  21. data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +9 -17
  22. data/lib/ratatui_ruby/devtools/tasks/bump/no_crate.rb +19 -0
  23. data/lib/ratatui_ruby/devtools/tasks/bump/no_links.rb +22 -0
  24. data/lib/ratatui_ruby/devtools/tasks/bump/patch_release.rb +25 -0
  25. data/lib/ratatui_ruby/devtools/tasks/bump/release_branch.rb +17 -0
  26. data/lib/ratatui_ruby/devtools/tasks/bump/release_from_trunk.rb +48 -0
  27. data/lib/ratatui_ruby/devtools/tasks/bump/repository.rb +143 -0
  28. data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +65 -59
  29. data/lib/ratatui_ruby/devtools/tasks/bump/rust_crate.rb +42 -0
  30. data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +127 -10
  31. data/lib/ratatui_ruby/devtools/tasks/bump/source_hut_project.rb +26 -0
  32. data/lib/ratatui_ruby/devtools/tasks/bump/unbranched_release.rb +20 -0
  33. data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +19 -1
  34. data/lib/ratatui_ruby/devtools/tasks/bump_tasks.rb +47 -0
  35. data/lib/ratatui_ruby/devtools/tasks/cargo_tasks.rb +57 -0
  36. data/lib/ratatui_ruby/devtools/tasks/doc/documentation.rb +59 -0
  37. data/lib/ratatui_ruby/devtools/tasks/doc/link/file_url.rb +30 -0
  38. data/lib/ratatui_ruby/devtools/tasks/doc/link/relative_path.rb +61 -0
  39. data/lib/ratatui_ruby/devtools/tasks/doc/link/web_url.rb +55 -0
  40. data/lib/ratatui_ruby/devtools/tasks/doc/link.rb +52 -0
  41. data/lib/ratatui_ruby/devtools/tasks/doc/link_audit.rb +116 -0
  42. data/lib/ratatui_ruby/devtools/tasks/doc/problem.rb +40 -0
  43. data/lib/ratatui_ruby/devtools/tasks/doc/source_file.rb +93 -0
  44. data/lib/ratatui_ruby/devtools/tasks/doc_tasks.rb +937 -0
  45. data/lib/ratatui_ruby/devtools/tasks/license/blame.rb +72 -0
  46. data/lib/ratatui_ruby/devtools/tasks/license/changed_files.rb +34 -0
  47. data/lib/ratatui_ruby/devtools/tasks/license/contribution.rb +12 -0
  48. data/lib/ratatui_ruby/devtools/tasks/license/copyright.rb +47 -0
  49. data/lib/ratatui_ruby/devtools/tasks/license/directory_licensing.rb +42 -0
  50. data/lib/ratatui_ruby/devtools/tasks/license/existing_header.rb +21 -0
  51. data/lib/ratatui_ruby/devtools/tasks/license/header_revision.rb +45 -0
  52. data/lib/ratatui_ruby/devtools/tasks/license/licensed_file.rb +39 -0
  53. data/lib/ratatui_ruby/devtools/tasks/license/licensing.rb +35 -0
  54. data/lib/ratatui_ruby/devtools/tasks/license/markdown_document.rb +23 -0
  55. data/lib/ratatui_ruby/devtools/tasks/license/markdown_files.rb +31 -0
  56. data/lib/ratatui_ruby/devtools/tasks/license/markdown_header_syntax.rb +104 -0
  57. data/lib/ratatui_ruby/devtools/tasks/license/markdown_snippet_syntax.rb +159 -0
  58. data/lib/ratatui_ruby/devtools/tasks/license/missing_header.rb +18 -0
  59. data/lib/ratatui_ruby/devtools/tasks/license/rbs_files.rb +27 -0
  60. data/lib/ratatui_ruby/devtools/tasks/license/rbs_header_syntax.rb +31 -0
  61. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_comment.rb +51 -0
  62. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_examples.rb +41 -0
  63. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_snippet_syntax.rb +119 -0
  64. data/lib/ratatui_ruby/devtools/tasks/license/renewal.rb +17 -0
  65. data/lib/ratatui_ruby/devtools/tasks/license/reuse_annotated_file.rb +12 -0
  66. data/lib/ratatui_ruby/devtools/tasks/license/reuse_tool.rb +34 -0
  67. data/lib/ratatui_ruby/devtools/tasks/license/ruby_comment_header.rb +49 -0
  68. data/lib/ratatui_ruby/devtools/tasks/license/ruby_files.rb +36 -0
  69. data/lib/ratatui_ruby/devtools/tasks/license/ruby_header_syntax.rb +39 -0
  70. data/lib/ratatui_ruby/devtools/tasks/license/rust_files.rb +27 -0
  71. data/lib/ratatui_ruby/devtools/tasks/license/rust_header_syntax.rb +59 -0
  72. data/lib/ratatui_ruby/devtools/tasks/license/snippet.rb +44 -0
  73. data/lib/ratatui_ruby/devtools/tasks/license/snippet_file.rb +29 -0
  74. data/lib/ratatui_ruby/devtools/tasks/license/snippet_opening.rb +39 -0
  75. data/lib/ratatui_ruby/devtools/tasks/license/snippet_revision.rb +75 -0
  76. data/lib/ratatui_ruby/devtools/tasks/license/spdx_header.rb +42 -0
  77. data/lib/ratatui_ruby/devtools/tasks/license/unknown_file.rb +12 -0
  78. data/lib/ratatui_ruby/devtools/tasks/license/unlicensed_files.rb +69 -0
  79. data/lib/ratatui_ruby/devtools/tasks/license/wrapped_snippet.rb +30 -0
  80. data/lib/ratatui_ruby/devtools/tasks/license_tasks.rb +71 -0
  81. data/lib/ratatui_ruby/devtools/tasks/lint/gem_lockfile.rb +29 -0
  82. data/lib/ratatui_ruby/devtools/tasks/lint/supported_rubies.rb +18 -0
  83. data/lib/ratatui_ruby/devtools/tasks/lint_tasks.rb +100 -0
  84. data/lib/ratatui_ruby/devtools/tasks/release/platform_gem.rb +21 -12
  85. data/lib/ratatui_ruby/devtools/tasks/release/versioned_binary.rb +1 -1
  86. data/lib/ratatui_ruby/devtools/tasks/release_tasks.rb +80 -0
  87. data/lib/ratatui_ruby/devtools/tasks/reuse_tasks.rb +39 -0
  88. data/lib/ratatui_ruby/devtools/tasks/{test.rake → test_tasks.rb} +13 -4
  89. data/lib/ratatui_ruby/devtools/tasks/website/hosted_source_tree.rb +24 -0
  90. data/lib/ratatui_ruby/devtools/tasks/website/index_page.rb +1 -4
  91. data/lib/ratatui_ruby/devtools/tasks/website/source_tree.rb +63 -0
  92. data/lib/ratatui_ruby/devtools/tasks/website/versioned_documentation.rb +14 -13
  93. data/lib/ratatui_ruby/devtools/tasks/website_tasks.rb +49 -0
  94. data/lib/ratatui_ruby/devtools/templates/.github/workflows/build-gems.yml.erb +7 -1
  95. data/lib/ratatui_ruby/devtools/templates/.github/workflows/ci.yml.erb +2 -2
  96. data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +2 -1
  97. data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +1 -1
  98. data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +1 -1
  99. data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +3 -1
  100. data/lib/ratatui_ruby/devtools/templates/CONTRIBUTING.md.erb +3 -3
  101. data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +2 -3
  102. data/lib/ratatui_ruby/devtools/templates/README.md.erb +7 -7
  103. data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +1 -1
  104. data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +5 -1
  105. data/lib/ratatui_ruby/devtools/templates/bin/setup.ps1.erb +5 -1
  106. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +2 -2
  107. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +3 -3
  108. data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +4 -4
  109. data/lib/ratatui_ruby/devtools/templates/ext/Cargo.toml.erb +1 -1
  110. data/lib/ratatui_ruby/devtools/templates/gemspec.erb +1 -1
  111. data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +3 -3
  112. data/lib/ratatui_ruby/devtools/version.rb +1 -1
  113. data/lib/ratatui_ruby/devtools.rb +54 -71
  114. data/mise.toml +2 -2
  115. data/sig/ratatui_ruby/devtools.rbs +16 -1
  116. metadata +93 -34
  117. data/.builds/ruby-4.0.yml +0 -38
  118. data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +0 -21
  119. data/lib/ratatui_ruby/devtools/tasks/bump.rake +0 -80
  120. data/lib/ratatui_ruby/devtools/tasks/cargo.rake +0 -47
  121. data/lib/ratatui_ruby/devtools/tasks/doc.rake +0 -887
  122. data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +0 -276
  123. data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +0 -236
  124. data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +0 -143
  125. data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +0 -353
  126. data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +0 -186
  127. data/lib/ratatui_ruby/devtools/tasks/license.rake +0 -91
  128. data/lib/ratatui_ruby/devtools/tasks/lint.rake +0 -80
  129. data/lib/ratatui_ruby/devtools/tasks/release.rake +0 -87
  130. data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +0 -7
  131. data/lib/ratatui_ruby/devtools/tasks/reuse.rake +0 -104
  132. data/lib/ratatui_ruby/devtools/tasks/website.rake +0 -37
  133. data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +0 -47
  134. data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +0 -45
  135. data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +0 -8
@@ -1,276 +0,0 @@
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
- # Ensures markdown files have correct SPDX headers.
9
- #
10
- # Open source projects need license headers in every file. REUSE compliance
11
- # requires SPDX format. Adding and updating these headers by hand is tedious.
12
- # Years drift. Contributors are forgotten.
13
- #
14
- # This script processes markdown files. It adds CC-BY-SA-4.0 headers where
15
- # missing. It updates copyright years based on git history. It preserves
16
- # existing contributors.
17
- #
18
- # Run it as part of license:headers:md rake task.
19
- #
20
- # === Example
21
- #
22
- # ruby tasks/license/headers_md.rb README.md
23
- # ruby tasks/license/headers_md.rb doc/
24
- # ruby tasks/license/headers_md.rb # all .md files
25
- #
26
- # Rules:
27
- # - Ensures file has CC-BY-SA-4.0 license header with YOUR copyright
28
- # - Updates years for EXISTING contributors based on git blame + Co-Authored-By
29
- # - Does NOT add new contributors from git history - only updates existing ones
30
- # - Uses non-code-block lines for year calculation
31
- # - Adds header with YOUR copyright if missing
32
-
33
- require_relative "license_utils"
34
-
35
- # Your name for copyright headers.
36
- YOUR_NAME = "Kerrick Long"
37
-
38
- # Your email for copyright headers.
39
- YOUR_EMAIL = "me@kerricklong.com"
40
-
41
- # Identifiers used to match your contributions in git history.
42
- YOUR_IDENTIFIERS = [YOUR_NAME, YOUR_EMAIL].freeze
43
-
44
- # Full copyright string for headers.
45
- YOUR_COPYRIGHT = "#{YOUR_NAME} <#{YOUR_EMAIL}>"
46
-
47
- # The SPDX license identifier for markdown documentation.
48
- LICENSE = "CC-BY-SA-4.0"
49
-
50
- # Identifies fenced code blocks in markdown content.
51
- #
52
- # Copyright years come from git blame. Code blocks contain pasted content,
53
- # not original prose. Blaming code block lines produces wrong contributors.
54
- # Exclude these ranges when calculating copyright years.
55
- #
56
- # [lines] Array of line strings from the file.
57
- def find_code_blocks(lines)
58
- blocks = []
59
- i = 0
60
-
61
- while i < lines.length
62
- line = lines[i]
63
-
64
- if line =~ /^(````*)(\w*)$/
65
- fence_marker = $1
66
- fence_start = i
67
- re_end = /^#{Regexp.escape(fence_marker)}$/
68
-
69
- j = i + 1
70
- while j < lines.length
71
- if lines[j] =~ re_end
72
- blocks << { start: fence_start, end: j }
73
- i = j
74
- break
75
- end
76
- j += 1
77
- end
78
- end
79
-
80
- i += 1
81
- end
82
-
83
- blocks
84
- end
85
-
86
- # Calculates line ranges outside code blocks.
87
- #
88
- # Copyright years come from git blame. Blaming the entire file includes code
89
- # blocks. This function returns only prose ranges for accurate year lookup.
90
- #
91
- # [lines] Array of line strings from the file.
92
- def get_non_code_line_ranges(lines)
93
- header_end = 0
94
- if lines[0]&.include?("<!--")
95
- (0...(lines.length)).each do |i|
96
- if lines[i].include?("-->")
97
- header_end = i + 1
98
- break
99
- end
100
- end
101
- end
102
-
103
- code_blocks = find_code_blocks(lines)
104
- non_code_ranges = []
105
- current_line = header_end
106
-
107
- code_blocks.each do |block|
108
- if current_line < block[:start]
109
- non_code_ranges << [current_line + 1, block[:start]]
110
- end
111
- current_line = block[:end] + 1
112
- end
113
-
114
- if current_line < lines.length
115
- non_code_ranges << [current_line + 1, lines.length]
116
- end
117
-
118
- non_code_ranges
119
- end
120
-
121
- # Extracts existing SPDX header from markdown content.
122
- #
123
- # Files may already have headers. Updating requires parsing existing copyright
124
- # holders and years. This extracts them for comparison and update.
125
- #
126
- # [lines] Array of line strings from the file.
127
- def parse_existing_header(lines)
128
- return nil unless lines[0]&.include?("<!--")
129
-
130
- header_end = nil
131
- copyrights = []
132
- license = nil
133
-
134
- (0...(lines.length)).each do |i|
135
- line = lines[i]
136
-
137
- if line =~ /SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$/
138
- copyrights << { year: $1.to_i, holder: $2.strip }
139
- # REUSE-IgnoreStart
140
- elsif line =~ /SPDX-License-Identifier:\s*(.+)$/
141
- # REUSE-IgnoreEnd
142
- license = $1.strip
143
- end
144
-
145
- if line.include?("-->")
146
- header_end = i
147
- break
148
- end
149
- end
150
-
151
- return nil if header_end.nil?
152
- return nil if copyrights.empty? && license.nil?
153
-
154
- { end_line: header_end, copyrights:, license: }
155
- end
156
-
157
- # Updates or adds SPDX headers for a single markdown file.
158
- #
159
- # Each file needs correct CC-BY-SA-4.0 headers with accurate copyright years.
160
- # Processing involves reading, parsing, querying git, and rewriting. This
161
- # function orchestrates that workflow.
162
- #
163
- # [filepath] Path to the markdown file.
164
- def process_file(filepath)
165
- content = File.read(filepath)
166
- lines = content.lines
167
-
168
- non_code_ranges = get_non_code_line_ranges(lines)
169
-
170
- # Get contributors from non-code lines for year lookups
171
- all_contributors = {}
172
- non_code_ranges.each do |start_line, end_line|
173
- range_contributors = LicenseUtils.get_contributors_for_lines(filepath, start_line, end_line)
174
- range_contributors.each do |contributor, year|
175
- all_contributors[contributor] = [all_contributors[contributor] || 0, year].max
176
- end
177
- end
178
-
179
- your_year = nil
180
- all_contributors.each do |contributor, year|
181
- if YOUR_IDENTIFIERS.any? { |id| contributor.include?(id) }
182
- your_year = [your_year || 0, year].max
183
- end
184
- end
185
- your_year ||= Date.today.year
186
-
187
- existing = parse_existing_header(lines)
188
-
189
- if existing
190
- # Only update years for EXISTING contributors
191
- needs_update = false
192
- updated_copyrights = []
193
-
194
- existing[:copyrights].each do |c|
195
- git_year = nil
196
- all_contributors.each do |contributor, year|
197
- if c[:holder].split.any? { |word| contributor.include?(word) }
198
- git_year = [git_year || 0, year].max
199
- end
200
- end
201
-
202
- if git_year && git_year != c[:year]
203
- puts " Updated #{c[:holder].split.first}'s copyright year: #{c[:year]} -> #{git_year}"
204
- updated_copyrights << { year: git_year, holder: c[:holder] }
205
- needs_update = true
206
- else
207
- updated_copyrights << c
208
- end
209
- end
210
-
211
- # Check if YOUR year needs updating
212
- your_existing = updated_copyrights.find { |c| YOUR_IDENTIFIERS.any? { |id| c[:holder].include?(id) } }
213
- if your_existing.nil?
214
- puts " Adding your copyright"
215
- updated_copyrights << { year: your_year, holder: YOUR_COPYRIGHT }
216
- needs_update = true
217
- end
218
-
219
- if existing[:license] != LICENSE
220
- puts " Fixing license: #{existing[:license]} -> #{LICENSE}"
221
- needs_update = true
222
- end
223
-
224
- if needs_update
225
- # REUSE-IgnoreStart
226
- header_lines = ["<!--\n"]
227
- updated_copyrights.each do |c|
228
- header_lines << " SPDX-FileCopyrightText: #{c[:year]} #{c[:holder]}\n"
229
- end
230
- header_lines << " SPDX-License-Identifier: #{LICENSE}\n"
231
- header_lines << "-->\n"
232
- # REUSE-IgnoreEnd
233
-
234
- remaining = lines[(existing[:end_line] + 1)..]
235
- File.write(filepath, header_lines.join + remaining.join)
236
- puts "Updated: #{filepath}"
237
- end
238
- else
239
- # No header - add one with YOUR copyright only
240
- # REUSE-IgnoreStart
241
- header = "<!--\n SPDX-FileCopyrightText: #{your_year} #{YOUR_COPYRIGHT}\n SPDX-License-Identifier: #{LICENSE}\n-->\n"
242
- # REUSE-IgnoreEnd
243
-
244
- File.write(filepath, header + content)
245
- puts "Added header: #{filepath}"
246
- end
247
- end
248
-
249
- # Finds markdown files to process.
250
- #
251
- # License automation runs on file sets. Users may specify paths or want all
252
- # files. This handles both cases using git ls-files for tracking.
253
- #
254
- # [paths] Explicit paths to process, or empty for all tracked .md files.
255
- def find_md_files(paths)
256
- if paths.empty?
257
- `git ls-files '*.md'`.split("\n")
258
- else
259
- paths.flat_map do |path|
260
- if File.directory?(path)
261
- `git ls-files '#{path}/**/*.md'`.split("\n")
262
- else
263
- path
264
- end
265
- end
266
- end
267
- end
268
-
269
- if __FILE__ == $0
270
- paths = ARGV.empty? ? [] : ARGV
271
- files = find_md_files(paths)
272
-
273
- files.each do |file|
274
- process_file(file)
275
- end
276
- end
@@ -1,236 +0,0 @@
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
- # Script to ensure Ruby files have correct SPDX file headers.
9
- #
10
- # Usage: ruby tasks/license/headers_rb.rb [path...]
11
- #
12
- # If no paths are given, processes lib/, ext/, test/, examples/, tasks/, bin/.
13
- #
14
- # License selection by directory:
15
- # - lib/, ext/, test/ → LGPL-3.0-or-later
16
- # - examples/widget_*, examples/verify_* → MIT-0
17
- # - examples/app_*, tasks/, bin/ → AGPL-3.0-or-later
18
-
19
- require_relative "license_utils"
20
-
21
- YOUR_NAME = "Kerrick Long"
22
- YOUR_EMAIL = "me@kerricklong.com"
23
- YOUR_IDENTIFIERS = [YOUR_NAME, YOUR_EMAIL].freeze
24
- YOUR_COPYRIGHT = "#{YOUR_NAME} <#{YOUR_EMAIL}>"
25
-
26
- # Selects the appropriate license based on file location.
27
- #
28
- # Different parts of the codebase have different licenses. Library code is
29
- # LGPL. Examples are MIT-0 or AGPL. This function routes files to their
30
- # correct license by path pattern.
31
- #
32
- # [filepath] Path to the Ruby file.
33
- def license_for_file(filepath)
34
- case filepath
35
- when %r{^(lib|sig/ratatui_ruby|ext|test)/}
36
- "LGPL-3.0-or-later"
37
- when %r{^(examples|sig/examples)/(widget_|verify_)}
38
- "MIT-0"
39
- else
40
- "AGPL-3.0-or-later"
41
- end
42
- end
43
-
44
- # Extracts existing SPDX header from Ruby file content.
45
- #
46
- # Files may already have headers. Updating requires parsing existing copyright
47
- # holders and years. This extracts them for comparison and update.
48
- #
49
- # [lines] Array of line strings from the file.
50
- def parse_existing_header(lines)
51
- # Returns { end_line:, copyrights: [{year:, holder:}], license: }
52
- # REUSE-IgnoreStart
53
- # Ruby files typically have:
54
- # # frozen_string_literal: true
55
- # (blank line)
56
- # #--
57
- # # SPDX-FileCopyrightText: YYYY Name
58
- # # SPDX-License-Identifier: LICENSE
59
- # #++
60
- # REUSE-IgnoreEnd
61
-
62
- copyrights = []
63
- license = nil
64
- header_end = nil
65
- found_spdx = false
66
-
67
- lines.each_with_index do |line, i|
68
- if line =~ /^#\s*SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$/
69
- copyrights << { year: $1.to_i, holder: $2.strip }
70
- found_spdx = true
71
- # REUSE-IgnoreStart
72
- elsif line =~ /^#\s*SPDX-License-Identifier:\s*(.+)$/
73
- # REUSE-IgnoreEnd
74
- license = $1.strip
75
- found_spdx = true
76
- elsif line =~ /^#\+\+\s*$/ && found_spdx
77
- header_end = i
78
- break
79
- end
80
- end
81
-
82
- return nil if copyrights.empty? && license.nil?
83
-
84
- { end_line: header_end || 0, copyrights:, license: }
85
- end
86
-
87
- # Updates or adds SPDX headers for a single Ruby file.
88
- #
89
- # Each file needs correct license headers with accurate copyright years.
90
- # Processing involves reading, parsing, querying git, and rewriting. This
91
- # function orchestrates that workflow.
92
- #
93
- # [filepath] Path to the Ruby file.
94
- def process_file(filepath)
95
- content = File.read(filepath)
96
- lines = content.lines
97
-
98
- target_license = license_for_file(filepath)
99
-
100
- # Get contributors from git for year lookups
101
- all_contributors = LicenseUtils.get_contributors_for_lines(filepath)
102
- your_year = LicenseUtils.get_your_latest_year(filepath, YOUR_IDENTIFIERS)
103
-
104
- existing = parse_existing_header(lines)
105
-
106
- if existing
107
- # File has existing header - only update years for EXISTING contributors
108
- needs_update = false
109
- updated_copyrights = []
110
-
111
- existing[:copyrights].each do |c|
112
- # Find this contributor's latest year from git
113
- git_year = nil
114
- all_contributors.each do |contributor, year|
115
- if c[:holder].split.any? { |word| contributor.include?(word) }
116
- git_year = [git_year || 0, year].max
117
- end
118
- end
119
-
120
- if git_year && git_year != c[:year]
121
- puts " Updated #{c[:holder].split.first}'s copyright year: #{c[:year]} -> #{git_year}"
122
- updated_copyrights << { year: git_year, holder: c[:holder] }
123
- needs_update = true
124
- else
125
- updated_copyrights << c
126
- end
127
- end
128
-
129
- # Check if YOUR year needs updating (if you're a contributor)
130
- your_existing = updated_copyrights.find { |c| YOUR_IDENTIFIERS.any? { |id| c[:holder].include?(id) } }
131
- if your_existing.nil?
132
- puts " Adding your copyright"
133
- updated_copyrights << { year: your_year, holder: YOUR_COPYRIGHT }
134
- needs_update = true
135
- end
136
-
137
- # Check license
138
- if existing[:license] != target_license
139
- puts " Fixing license: #{existing[:license]} -> #{target_license}"
140
- needs_update = true
141
- end
142
-
143
- if needs_update
144
- frozen_string = lines[0].include?("frozen_string_literal") ? lines[0] : nil
145
-
146
- header_lines = []
147
- header_lines << "# frozen_string_literal: true\n" unless frozen_string
148
- header_lines << "\n" if frozen_string.nil? && !lines[0].strip.empty?
149
- header_lines << "#--\n"
150
-
151
- # REUSE-IgnoreStart
152
- updated_copyrights.each do |c|
153
- header_lines << "# SPDX-FileCopyrightText: #{c[:year]} #{c[:holder]}\n"
154
- end
155
- header_lines << "# SPDX-License-Identifier: #{target_license}\n"
156
- # REUSE-IgnoreEnd
157
- header_lines << "#++\n"
158
-
159
- content_start = existing[:end_line] + 1
160
- while content_start < lines.length && lines[content_start].strip.empty?
161
- content_start += 1
162
- end
163
-
164
- remaining = lines[content_start..]
165
-
166
- new_content = if frozen_string
167
- "#{frozen_string}\n#{header_lines.join}\n#{remaining.join}"
168
- else
169
- "#{header_lines.join}\n#{remaining.join}"
170
- end
171
-
172
- File.write(filepath, new_content)
173
- puts "Updated: #{filepath}"
174
- end
175
- else
176
- # No header - add one with YOUR copyright only
177
- frozen_line = lines[0]&.include?("frozen_string_literal") ? lines.shift : nil
178
-
179
- header = []
180
- header << "# frozen_string_literal: true\n\n" unless frozen_line
181
- header << "#--\n"
182
- # REUSE-IgnoreStart
183
- header << "# SPDX-FileCopyrightText: #{your_year} #{YOUR_COPYRIGHT}\n"
184
- header << "# SPDX-License-Identifier: #{target_license}\n"
185
- # REUSE-IgnoreEnd
186
- header << "#++\n\n"
187
-
188
- if frozen_line
189
- File.write(filepath, "#{frozen_line}\n#{header.join}#{lines.join}")
190
- else
191
- File.write(filepath, header.join + lines.join)
192
- end
193
- puts "Added header: #{filepath}"
194
- end
195
- end
196
-
197
- # Finds Ruby files to process.
198
- #
199
- # License automation runs on file sets. Users may specify paths or want all
200
- # lib/ext/test files. This handles both cases using git ls-files for tracking.
201
- #
202
- # [paths] Explicit paths to process, or empty for default directories.
203
- def find_rb_files(paths)
204
- if paths.empty?
205
- # Process all relevant directories
206
- dirs = %w[lib ext test examples tasks bin sig]
207
- files = dirs.flat_map do |dir|
208
- # Include both root files and subdirectory files, for both .rb and .rbs
209
- %w[rb rbs].flat_map do |ext|
210
- root_files = `git ls-files '#{dir}/*.#{ext}' 2>/dev/null`.split("\n")
211
- sub_files = `git ls-files '#{dir}/**/*.#{ext}' 2>/dev/null`.split("\n")
212
- root_files + sub_files
213
- end
214
- end
215
- files.uniq
216
- else
217
- paths.flat_map do |path|
218
- if File.directory?(path)
219
- rb_files = `git ls-files '#{path}/**/*.rb'`.split("\n")
220
- rbs_files = `git ls-files '#{path}/**/*.rbs'`.split("\n")
221
- rb_files + rbs_files
222
- else
223
- path
224
- end
225
- end
226
- end
227
- end
228
-
229
- if __FILE__ == $0
230
- paths = ARGV.empty? ? [] : ARGV
231
- files = find_rb_files(paths)
232
-
233
- files.each do |file|
234
- process_file(file)
235
- end
236
- end
@@ -1,143 +0,0 @@
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
- # Shared utility for detecting contributors from git blame and Co-Authored-By trailers.
9
- #
10
- # This module provides methods to:
11
- # - Get all contributors (authors and co-authors) who touched specific lines
12
- # - Track the latest year each contributor touched those lines
13
- # - Parse Co-Authored-By trailers from commit messages
14
-
15
- require "open3"
16
- require "date"
17
-
18
- # Extracts contributor information from git history.
19
- #
20
- # License headers need accurate copyright years and contributor names. Git
21
- # blame provides line-level authorship. Commit messages contain Co-Authored-By
22
- # trailers. Combining these sources manually is tedious.
23
- #
24
- # This module queries git blame and parses commit messages. It returns
25
- # contributor names with their latest modification years. Use it when
26
- # generating or updating SPDX headers.
27
- module LicenseUtils
28
- # A contributor with their latest modification year.
29
- #
30
- # [name] The contributor's display name.
31
- # [email] The contributor's email address.
32
- # [year] The most recent year they modified the file.
33
- Contributor = Data.define(:name, :email, :year)
34
-
35
- class << self
36
- # Get all contributors who touched lines in a file (or range of lines).
37
- # Returns a Hash of { "Name <email>" => year } mapping each contributor to their latest year.
38
- #
39
- # This considers both the commit author AND any Co-Authored-By trailers in commit messages.
40
- def get_contributors_for_lines(filepath, start_line = nil, end_line = nil)
41
- blame_cmd = if start_line && end_line
42
- %W[git blame -L #{start_line},#{end_line} --porcelain -- #{filepath}]
43
- else
44
- %W[git blame --porcelain -- #{filepath}]
45
- end
46
-
47
- output, _status = Open3.capture2(*blame_cmd)
48
-
49
- contributors = {} # "Name <email>" => year
50
- commit_cache = {} # commit_hash => { year:, author:, co_authors: [] }
51
-
52
- current_commit = nil
53
-
54
- output.each_line do |line|
55
- if line =~ /^([a-f0-9]{40})/
56
- current_commit = $1
57
- elsif line =~ /^author (.+)$/
58
- commit_cache[current_commit] ||= {}
59
- commit_cache[current_commit][:author_name] = $1
60
- elsif line =~ /^author-mail <(.+)>$/
61
- commit_cache[current_commit] ||= {}
62
- commit_cache[current_commit][:author_email] = $1
63
- elsif line =~ /^author-time (\d+)$/
64
- commit_cache[current_commit] ||= {}
65
- timestamp = $1.to_i
66
- commit_cache[current_commit][:year] = Time.at(timestamp).year
67
- end
68
- end
69
-
70
- # Now fetch co-authors for each unique commit
71
- commit_cache.each do |commit_hash, data|
72
- next if commit_hash == "0" * 40 # Skip uncommitted lines
73
-
74
- # Get commit message for Co-Authored-By parsing
75
- msg_output, _status = Open3.capture2("git", "log", "-1", "--format=%B", commit_hash)
76
- co_authors = parse_co_authors(msg_output)
77
- data[:co_authors] = co_authors
78
-
79
- # Add author
80
- if data[:author_name] && data[:author_email]
81
- key = "#{data[:author_name]} <#{data[:author_email]}>"
82
- year = data[:year] || Date.today.year
83
- contributors[key] = [contributors[key] || 0, year].max
84
- end
85
-
86
- # Add co-authors with same year as commit
87
- co_authors.each do |ca|
88
- key = "#{ca[:name]} <#{ca[:email]}>"
89
- year = data[:year] || Date.today.year
90
- contributors[key] = [contributors[key] || 0, year].max
91
- end
92
- end
93
-
94
- contributors
95
- end
96
-
97
- # Get YOUR latest year contribution to the file/lines.
98
- # your_identifiers should be an array of strings that identify you (name, email fragments).
99
- def get_your_latest_year(filepath, your_identifiers, start_line = nil, end_line = nil)
100
- contributors = get_contributors_for_lines(filepath, start_line, end_line)
101
-
102
- your_year = nil
103
- contributors.each do |contributor, year|
104
- if your_identifiers.any? { |id| contributor.include?(id) }
105
- your_year = [your_year || 0, year].max
106
- end
107
- end
108
-
109
- your_year || Date.today.year
110
- end
111
-
112
- # Get all contributors EXCEPT you, with their latest years.
113
- # Returns array of { name:, email:, year: }
114
- def get_other_contributors(filepath, your_identifiers, start_line = nil, end_line = nil)
115
- contributors = get_contributors_for_lines(filepath, start_line, end_line)
116
-
117
- others = []
118
- contributors.each do |contributor, year|
119
- next if your_identifiers.any? { |id| contributor.include?(id) }
120
-
121
- # Parse "Name <email>" format
122
- if contributor =~ /^(.+?)\s*<(.+)>$/
123
- others << { name: $1.strip, email: $2.strip, year: }
124
- end
125
- end
126
-
127
- others
128
- end
129
-
130
- private def parse_co_authors(message)
131
- co_authors = []
132
-
133
- message.each_line do |line|
134
- # Match "Co-Authored-By: Name <email>" (case insensitive)
135
- if line =~ /^Co-Authored-By:\s*(.+?)\s*<(.+?)>\s*$/i
136
- co_authors << { name: $1.strip, email: $2.strip }
137
- end
138
- end
139
-
140
- co_authors
141
- end
142
- end
143
- end