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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +69 -7
- data/README.md +62 -21
- data/Rakefile +1 -1
- data/bin/setup +20 -6
- data/bin/setup.ps1 +74 -0
- data/doc/contributors/documentation_style.md +3 -4
- data/exe/scaffold +8 -342
- data/lib/ratatui_ruby/devtools/scaffold/scaffold_config.rb +67 -0
- data/lib/ratatui_ruby/devtools/scaffold/template_renderer.rb +282 -0
- data/lib/ratatui_ruby/devtools/task_scope.rb +24 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc_tasks.rb +31 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/bump_workflow.rb +71 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +60 -3
- data/lib/ratatui_ruby/devtools/tasks/bump/github_project.rb +26 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +3 -3
- data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +39 -24
- data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +9 -17
- data/lib/ratatui_ruby/devtools/tasks/bump/no_crate.rb +19 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/no_links.rb +22 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/patch_release.rb +25 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/release_branch.rb +17 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/release_from_trunk.rb +48 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/repository.rb +143 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +65 -59
- data/lib/ratatui_ruby/devtools/tasks/bump/rust_crate.rb +42 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +127 -10
- data/lib/ratatui_ruby/devtools/tasks/bump/source_hut_project.rb +26 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/unbranched_release.rb +20 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +19 -1
- data/lib/ratatui_ruby/devtools/tasks/bump_tasks.rb +47 -0
- data/lib/ratatui_ruby/devtools/tasks/cargo_tasks.rb +57 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/documentation.rb +59 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/link/file_url.rb +30 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/link/relative_path.rb +61 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/link/web_url.rb +55 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/link.rb +52 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/link_audit.rb +116 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/problem.rb +40 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/source_file.rb +93 -0
- data/lib/ratatui_ruby/devtools/tasks/doc_tasks.rb +937 -0
- data/lib/ratatui_ruby/devtools/tasks/license/blame.rb +78 -0
- data/lib/ratatui_ruby/devtools/tasks/license/changed_files.rb +34 -0
- data/lib/ratatui_ruby/devtools/tasks/license/contribution.rb +12 -0
- data/lib/ratatui_ruby/devtools/tasks/license/copyright.rb +47 -0
- data/lib/ratatui_ruby/devtools/tasks/license/directory_licensing.rb +42 -0
- data/lib/ratatui_ruby/devtools/tasks/license/existing_header.rb +21 -0
- data/lib/ratatui_ruby/devtools/tasks/license/header_revision.rb +45 -0
- data/lib/ratatui_ruby/devtools/tasks/license/licensed_file.rb +39 -0
- data/lib/ratatui_ruby/devtools/tasks/license/licensing.rb +35 -0
- data/lib/ratatui_ruby/devtools/tasks/license/markdown_document.rb +23 -0
- data/lib/ratatui_ruby/devtools/tasks/license/markdown_files.rb +31 -0
- data/lib/ratatui_ruby/devtools/tasks/license/markdown_header_syntax.rb +104 -0
- data/lib/ratatui_ruby/devtools/tasks/license/markdown_snippet_syntax.rb +159 -0
- data/lib/ratatui_ruby/devtools/tasks/license/missing_header.rb +18 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rbs_files.rb +27 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rbs_header_syntax.rb +33 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rdoc_comment.rb +51 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rdoc_examples.rb +41 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rdoc_snippet_syntax.rb +119 -0
- data/lib/ratatui_ruby/devtools/tasks/license/renewal.rb +17 -0
- data/lib/ratatui_ruby/devtools/tasks/license/reuse_annotated_file.rb +12 -0
- data/lib/ratatui_ruby/devtools/tasks/license/reuse_tool.rb +34 -0
- data/lib/ratatui_ruby/devtools/tasks/license/ruby_comment_header.rb +57 -0
- data/lib/ratatui_ruby/devtools/tasks/license/ruby_files.rb +36 -0
- data/lib/ratatui_ruby/devtools/tasks/license/ruby_header_syntax.rb +47 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rust_files.rb +27 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rust_header_syntax.rb +68 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippet.rb +44 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippet_file.rb +29 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippet_opening.rb +39 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippet_revision.rb +75 -0
- data/lib/ratatui_ruby/devtools/tasks/license/spdx_header.rb +42 -0
- data/lib/ratatui_ruby/devtools/tasks/license/unknown_file.rb +12 -0
- data/lib/ratatui_ruby/devtools/tasks/license/unlicensed_files.rb +69 -0
- data/lib/ratatui_ruby/devtools/tasks/license/wrapped_snippet.rb +30 -0
- data/lib/ratatui_ruby/devtools/tasks/license_tasks.rb +71 -0
- data/lib/ratatui_ruby/devtools/tasks/lint/gem_lockfile.rb +29 -0
- data/lib/ratatui_ruby/devtools/tasks/lint/supported_rubies.rb +18 -0
- data/lib/ratatui_ruby/devtools/tasks/lint_tasks.rb +100 -0
- data/lib/ratatui_ruby/devtools/tasks/release/platform_gem.rb +21 -12
- data/lib/ratatui_ruby/devtools/tasks/release/versioned_binary.rb +1 -1
- data/lib/ratatui_ruby/devtools/tasks/release_tasks.rb +80 -0
- data/lib/ratatui_ruby/devtools/tasks/reuse_tasks.rb +39 -0
- data/lib/ratatui_ruby/devtools/tasks/{test.rake → test_tasks.rb} +13 -4
- data/lib/ratatui_ruby/devtools/tasks/website/hosted_source_tree.rb +24 -0
- data/lib/ratatui_ruby/devtools/tasks/website/index_page.rb +1 -4
- data/lib/ratatui_ruby/devtools/tasks/website/source_tree.rb +63 -0
- data/lib/ratatui_ruby/devtools/tasks/website/versioned_documentation.rb +14 -13
- data/lib/ratatui_ruby/devtools/tasks/website_tasks.rb +49 -0
- data/lib/ratatui_ruby/devtools/templates/.github/workflows/build-gems.yml.erb +15 -9
- data/lib/ratatui_ruby/devtools/templates/.github/workflows/ci.yml.erb +2 -2
- data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +2 -1
- data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +1 -1
- data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +1 -1
- data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +3 -1
- data/lib/ratatui_ruby/devtools/templates/CONTRIBUTING.md.erb +3 -3
- data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +2 -3
- data/lib/ratatui_ruby/devtools/templates/README.md.erb +7 -7
- data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +1 -1
- data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +5 -1
- data/lib/ratatui_ruby/devtools/templates/bin/setup.ps1.erb +5 -1
- data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +2 -2
- data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +3 -3
- data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +4 -4
- data/lib/ratatui_ruby/devtools/templates/ext/Cargo.toml.erb +1 -1
- data/lib/ratatui_ruby/devtools/templates/gemspec.erb +1 -1
- data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +3 -3
- data/lib/ratatui_ruby/devtools/version.rb +1 -1
- data/lib/ratatui_ruby/devtools.rb +54 -71
- data/mise.toml +2 -2
- data/sig/ratatui_ruby/devtools.rbs +16 -1
- metadata +93 -34
- data/.builds/ruby-4.0.yml +0 -38
- data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +0 -21
- data/lib/ratatui_ruby/devtools/tasks/bump.rake +0 -80
- data/lib/ratatui_ruby/devtools/tasks/cargo.rake +0 -47
- data/lib/ratatui_ruby/devtools/tasks/doc.rake +0 -887
- data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +0 -276
- data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +0 -236
- data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +0 -143
- data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +0 -353
- data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +0 -186
- data/lib/ratatui_ruby/devtools/tasks/license.rake +0 -91
- data/lib/ratatui_ruby/devtools/tasks/lint.rake +0 -80
- data/lib/ratatui_ruby/devtools/tasks/release.rake +0 -87
- data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +0 -7
- data/lib/ratatui_ruby/devtools/tasks/reuse.rake +0 -104
- data/lib/ratatui_ruby/devtools/tasks/website.rake +0 -37
- data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +0 -47
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +0 -45
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +0 -8
|
@@ -0,0 +1,78 @@
|
|
|
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 "date"
|
|
9
|
+
require "open3"
|
|
10
|
+
require_relative "contribution"
|
|
11
|
+
|
|
12
|
+
# Who last touched each line of a file. Given +contents+, it blames that text
|
|
13
|
+
# against the file's history instead of what's on disk, so line numbers can
|
|
14
|
+
# refer to lines that are about to be written.
|
|
15
|
+
#
|
|
16
|
+
# Only commit authors count. Co-Authored-By trailers credit AI tools, which
|
|
17
|
+
# can't hold copyright, and people whose idea a commit took up without their
|
|
18
|
+
# code; git history already credits both.
|
|
19
|
+
#
|
|
20
|
+
# Commits listed in .git-blame-ignore-revs count for no one, so a change too
|
|
21
|
+
# small to hold copyright, such as a corrected path, adds no notice. Git reads
|
|
22
|
+
# that file only when each contributor configures it, so blame names it here.
|
|
23
|
+
class Blame < Data.define(:path, :contents) # :nodoc:
|
|
24
|
+
UNCOMMITTED = "0" * 40
|
|
25
|
+
IGNORE_REVS = ".git-blame-ignore-revs"
|
|
26
|
+
|
|
27
|
+
def initialize(path:, contents: nil)
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Each author once, with the latest year they touched the lines.
|
|
32
|
+
# [line_ranges] Pairs of first and last line numbers, counting from 1.
|
|
33
|
+
# Omit them to blame the whole file.
|
|
34
|
+
# [uncommitted_by] The author to credit, this year, for lines not yet
|
|
35
|
+
# committed. Without one, those lines count for nobody.
|
|
36
|
+
def contributions(line_ranges = nil, uncommitted_by: nil)
|
|
37
|
+
found = (line_ranges || [nil]).flat_map { |range| contributions_in(range, uncommitted_by) }
|
|
38
|
+
found.group_by(&:author).map { |_, same| same.max_by(&:year) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private def contributions_in(range, uncommitted_by)
|
|
42
|
+
# A file git doesn't track yet is uncommitted throughout.
|
|
43
|
+
commits = commits_in(range) || { UNCOMMITTED => {} }
|
|
44
|
+
commits.flat_map do |commit, author|
|
|
45
|
+
if commit == UNCOMMITTED
|
|
46
|
+
next [] unless uncommitted_by
|
|
47
|
+
|
|
48
|
+
next [Contribution.new(author: uncommitted_by, year: Date.today.year)]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
next [] unless author[:name] && author[:email]
|
|
52
|
+
|
|
53
|
+
year = author[:year] || Date.today.year
|
|
54
|
+
[Contribution.new(author: Repository::Author.new(name: author[:name].strip, email: author[:email].strip), year:)]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private def commits_in(range)
|
|
59
|
+
command = %w[git blame --porcelain]
|
|
60
|
+
command += ["-L", range.join(",")] if range
|
|
61
|
+
command += ["--contents", "-"] if contents
|
|
62
|
+
command += ["--ignore-revs-file", IGNORE_REVS] if File.exist?(IGNORE_REVS)
|
|
63
|
+
output, status = Open3.capture2(*command, "--", path, stdin_data: contents.to_s, err: File::NULL)
|
|
64
|
+
return unless status.success?
|
|
65
|
+
|
|
66
|
+
commits = {}
|
|
67
|
+
commit = nil
|
|
68
|
+
output.each_line do |line|
|
|
69
|
+
case line
|
|
70
|
+
when /^([a-f0-9]{40})/ then commit = $1
|
|
71
|
+
when /^author (.+)$/ then (commits[commit] ||= {})[:name] = $1
|
|
72
|
+
when /^author-mail <(.+)>$/ then (commits[commit] ||= {})[:email] = $1
|
|
73
|
+
when /^author-time (\d+)$/ then (commits[commit] ||= {})[:year] = Time.at($1.to_i).year
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
commits
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
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 "../bump/repository"
|
|
9
|
+
require_relative "markdown_files"
|
|
10
|
+
require_relative "rbs_files"
|
|
11
|
+
require_relative "ruby_files"
|
|
12
|
+
require_relative "rust_files"
|
|
13
|
+
|
|
14
|
+
class ChangedFiles < Data.define(:repository, :licensing) # :nodoc:
|
|
15
|
+
# Licenses what changed and says what it did.
|
|
16
|
+
def license
|
|
17
|
+
languages = [
|
|
18
|
+
MarkdownFiles.changed(repository),
|
|
19
|
+
RubyFiles.changed(repository, licensing),
|
|
20
|
+
RbsFiles.changed(repository, licensing),
|
|
21
|
+
RustFiles.changed(repository, licensing),
|
|
22
|
+
]
|
|
23
|
+
changed = languages.reject { |language| language.count.zero? }
|
|
24
|
+
return ["No changed #{sentence(languages.map(&:label))} files to process"] if changed.empty?
|
|
25
|
+
|
|
26
|
+
changed.flat_map do |language|
|
|
27
|
+
["Processing #{language.count} changed #{language.label} file(s)...", *language.license]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private def sentence(words)
|
|
32
|
+
"#{words[0...-1].join(', ')}, or #{words.last}"
|
|
33
|
+
end
|
|
34
|
+
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
|
+
require_relative "../bump/repository"
|
|
9
|
+
|
|
10
|
+
# An author's work on a file, as of the latest year they touched it.
|
|
11
|
+
class Contribution < Data.define(:author, :year) # :nodoc:
|
|
12
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
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 "date"
|
|
9
|
+
require_relative "renewal"
|
|
10
|
+
|
|
11
|
+
class Copyright < Data.define(:year, :holder) # :nodoc:
|
|
12
|
+
def self.of(contribution)
|
|
13
|
+
new(year: contribution.year, holder: contribution.author.to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# For work with no history to credit, such as an empty file.
|
|
17
|
+
def self.yours(author)
|
|
18
|
+
new(year: Date.today.year, holder: author.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A holder matches an author when any word of the holder appears in the
|
|
22
|
+
# author's name and email, so "Jane Doe <...>" matches either name.
|
|
23
|
+
def self.latest_year(contributions, words)
|
|
24
|
+
contributions.select { |contribution| words.any? { |word| contribution.author.to_s.include?(word) } }
|
|
25
|
+
.map(&:year).max
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def held_by?(author)
|
|
29
|
+
[author.name, author.email].any? { |identifier| holder.include?(identifier) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Git can know less than the notice does, as when history was rewritten or
|
|
33
|
+
# the work came from elsewhere.
|
|
34
|
+
def renewal(contributions)
|
|
35
|
+
latest_year = self.class.latest_year(contributions, holder.split)
|
|
36
|
+
return Renewal.unchanged(self) unless latest_year && latest_year > year
|
|
37
|
+
|
|
38
|
+
Renewal.new(
|
|
39
|
+
value: with(year: latest_year),
|
|
40
|
+
notices: ["Updated #{holder.split.first}'s copyright year: #{year} -> #{latest_year}"]
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_s
|
|
45
|
+
"#{year} #{holder}"
|
|
46
|
+
end
|
|
47
|
+
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 "blame"
|
|
9
|
+
require_relative "licensing"
|
|
10
|
+
|
|
11
|
+
# Source code takes its license from where it lives, and everything below its
|
|
12
|
+
# license header counts toward an author's copyright year.
|
|
13
|
+
module DirectoryLicensing # :nodoc:
|
|
14
|
+
def initialize(licensing)
|
|
15
|
+
@licensing = licensing
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def covers?(path)
|
|
19
|
+
extensions.include?(File.extname(path).delete_prefix(".")) &&
|
|
20
|
+
directories.any? { |directory| path.start_with?("#{directory}/") }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def tracked_patterns
|
|
24
|
+
directories.flat_map do |directory|
|
|
25
|
+
extensions.flat_map { |extension| ["#{directory}/*.#{extension}", "#{directory}/**/*.#{extension}"] }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def license_for(path)
|
|
30
|
+
@licensing.license_for(path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Editing the license header, as when relicensing, isn't authorship of the
|
|
34
|
+
# file.
|
|
35
|
+
def contributions(path, lines, author)
|
|
36
|
+
Blame.new(path).contributions(header_in(lines).blame_ranges(lines), uncommitted_by: author)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private def after_header(lines, end_line)
|
|
40
|
+
lines.drop(end_line + 1).drop_while { |line| line.strip.empty? }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
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 file's license header, ending on +end_line+ counting from 0.
|
|
9
|
+
class ExistingHeader < Data.define(:header, :end_line) # :nodoc:
|
|
10
|
+
def revise(revision)
|
|
11
|
+
renewal = header.renewal(revision.contributions, revision.license)
|
|
12
|
+
revision.replace(renewal, end_line) if renewal.changed?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The lines below the header, as git blame counts them, from 1.
|
|
16
|
+
def blame_ranges(lines)
|
|
17
|
+
return [] if end_line + 1 >= lines.length
|
|
18
|
+
|
|
19
|
+
[[end_line + 2, lines.length]]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
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 "spdx_header"
|
|
9
|
+
|
|
10
|
+
# One file's license header brought up to date: the header the file has asks
|
|
11
|
+
# to be replaced, or the one it lacks asks to be added.
|
|
12
|
+
class HeaderRevision # :nodoc:
|
|
13
|
+
attr_reader :text
|
|
14
|
+
|
|
15
|
+
def initialize(path:, lines:, syntax:, author:)
|
|
16
|
+
@path = path
|
|
17
|
+
@lines = lines
|
|
18
|
+
@syntax = syntax
|
|
19
|
+
@author = author
|
|
20
|
+
@messages = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def contributions
|
|
24
|
+
@contributions ||= @syntax.contributions(@path, @lines, @author)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def license
|
|
28
|
+
@syntax.license_for(@path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def replace(renewal, end_line)
|
|
32
|
+
@text = @syntax.replaced(renewal.value, @lines, end_line)
|
|
33
|
+
@messages = [*renewal.notices.map { |notice| " #{notice}" }, "Updated: #{@path}"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add
|
|
37
|
+
@text = @syntax.added(SpdxHeader.of(@author, contributions, license), @lines)
|
|
38
|
+
@messages = ["Added header: #{@path}"]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# What the revision did, for whoever ran it.
|
|
42
|
+
def messages
|
|
43
|
+
@messages.dup
|
|
44
|
+
end
|
|
45
|
+
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_relative "../bump/repository"
|
|
9
|
+
require_relative "header_revision"
|
|
10
|
+
|
|
11
|
+
class LicensedFile < Data.define(:path, :syntax, :author) # :nodoc:
|
|
12
|
+
def self.changed(syntax, repository: Repository.new)
|
|
13
|
+
of(repository.changed_files.select { |path| syntax.covers?(path) }, syntax, repository)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.tracked(paths, syntax, repository: Repository.new)
|
|
17
|
+
of(repository.tracked_files_for(paths, syntax), syntax, repository)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Asking git for the author only when there are files keeps a run with
|
|
21
|
+
# nothing to annotate working where git has no identity, as in CI.
|
|
22
|
+
private_class_method def self.of(files, syntax, repository)
|
|
23
|
+
return [] if files.empty?
|
|
24
|
+
|
|
25
|
+
author = repository.author
|
|
26
|
+
files.map { |file| new(path: file, syntax:, author:) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Brings the file's header up to date and says what changed.
|
|
30
|
+
def annotate
|
|
31
|
+
# Binary mode keeps the file's own line endings, which text mode on
|
|
32
|
+
# Windows would turn into CRLF.
|
|
33
|
+
lines = File.read(path, mode: "rb", encoding: "UTF-8").lines
|
|
34
|
+
revision = HeaderRevision.new(path:, lines:, syntax:, author:)
|
|
35
|
+
syntax.header_in(lines).revise(revision)
|
|
36
|
+
File.write(path, revision.text, mode: "wb") if revision.text
|
|
37
|
+
revision.messages
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
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 "rubygems"
|
|
9
|
+
|
|
10
|
+
# The gem's own license is about linking, and an app links only the code it
|
|
11
|
+
# requires and the extensions it compiles. Code a gem treats as a large
|
|
12
|
+
# snippet is free to copy, like the snippets in its docs.
|
|
13
|
+
class Licensing < Data.define(:snippets, :linked, :runtime, :non_runtime) # :nodoc:
|
|
14
|
+
SNIPPET = "MIT-0"
|
|
15
|
+
|
|
16
|
+
def self.of(spec, snippets:, non_runtime:)
|
|
17
|
+
new(
|
|
18
|
+
snippets:,
|
|
19
|
+
linked: [*spec.require_paths, *spec.extensions.map { |extension| File.dirname(extension) }]
|
|
20
|
+
.map { |directory| "#{directory}/" },
|
|
21
|
+
runtime: spec.licenses.join(" OR "),
|
|
22
|
+
non_runtime:
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def license_for(path)
|
|
27
|
+
if path.start_with?(*snippets)
|
|
28
|
+
SNIPPET
|
|
29
|
+
elsif path.start_with?(*linked)
|
|
30
|
+
runtime
|
|
31
|
+
else
|
|
32
|
+
non_runtime
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
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 MarkdownDocument < Data.define(:lines) # :nodoc:
|
|
9
|
+
FENCE = /^(````*)\w*$/
|
|
10
|
+
|
|
11
|
+
def fence?(index)
|
|
12
|
+
lines[index].match?(FENCE)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The line that closes the fence opening at +index+, when one does.
|
|
16
|
+
def fence_end(index)
|
|
17
|
+
marker = lines[index][FENCE, 1]
|
|
18
|
+
return unless marker
|
|
19
|
+
|
|
20
|
+
closing = /^#{Regexp.escape(marker)}$/
|
|
21
|
+
(index + 1).upto(lines.length - 1).find { |later| lines[later].match?(closing) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
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 "markdown_header_syntax"
|
|
10
|
+
require_relative "markdown_snippet_syntax"
|
|
11
|
+
require_relative "snippet_file"
|
|
12
|
+
|
|
13
|
+
class MarkdownFiles < Data.define(:files) # :nodoc:
|
|
14
|
+
def self.changed(repository)
|
|
15
|
+
new(files: LicensedFile.changed(MarkdownHeaderSyntax.new, repository:))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def label
|
|
19
|
+
"Markdown"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def count
|
|
23
|
+
files.count
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def license
|
|
27
|
+
syntax = MarkdownSnippetSyntax.new
|
|
28
|
+
files.flat_map(&:annotate) +
|
|
29
|
+
files.flat_map { |file| SnippetFile.new(path: file.path, syntax:, author: file.author).annotate }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
require_relative "existing_header"
|
|
11
|
+
require_relative "markdown_document"
|
|
12
|
+
require_relative "missing_header"
|
|
13
|
+
require_relative "spdx_header"
|
|
14
|
+
|
|
15
|
+
class MarkdownHeaderSyntax # :nodoc:
|
|
16
|
+
LICENSE = "CC-BY-SA-4.0"
|
|
17
|
+
# REUSE-IgnoreStart
|
|
18
|
+
COPYRIGHT = /SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$/
|
|
19
|
+
LICENSE_LINE = /SPDX-License-Identifier:\s*(.+)$/
|
|
20
|
+
# REUSE-IgnoreEnd
|
|
21
|
+
|
|
22
|
+
def extensions
|
|
23
|
+
%w[md]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def covers?(path)
|
|
27
|
+
path.end_with?(".md")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def tracked_patterns
|
|
31
|
+
["*.md"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def license_for(_path)
|
|
35
|
+
LICENSE
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Code blocks hold examples rather than prose, so only prose lines count
|
|
39
|
+
# toward who wrote the document and when.
|
|
40
|
+
def contributions(path, lines, author)
|
|
41
|
+
Blame.new(path).contributions(prose_ranges(lines), uncommitted_by: author)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def header_in(lines)
|
|
45
|
+
closing = comment_end(lines)
|
|
46
|
+
return MissingHeader.new unless closing
|
|
47
|
+
|
|
48
|
+
comment = lines[..closing]
|
|
49
|
+
copyrights = comment.grep(COPYRIGHT) { Copyright.new(year: $1.to_i, holder: $2.strip) }
|
|
50
|
+
license = comment.grep(LICENSE_LINE) { $1.strip }.last
|
|
51
|
+
return MissingHeader.new unless copyrights.any? || license
|
|
52
|
+
|
|
53
|
+
ExistingHeader.new(header: SpdxHeader.new(copyrights:, license:), end_line: closing)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def added(header, lines)
|
|
57
|
+
"#{block(header)}#{lines.join}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def replaced(header, lines, end_line)
|
|
61
|
+
"#{block(header)}#{lines.drop(end_line + 1).join}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private def block(header)
|
|
65
|
+
"<!--\n#{header.commented(' ')}-->\n"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Line ranges counting from 1, between the header and each code block.
|
|
69
|
+
private def prose_ranges(lines)
|
|
70
|
+
start = header_length(lines)
|
|
71
|
+
ranges = []
|
|
72
|
+
code_blocks(lines).each do |first, last|
|
|
73
|
+
ranges << [start + 1, first] if start < first
|
|
74
|
+
start = last + 1
|
|
75
|
+
end
|
|
76
|
+
ranges << [start + 1, lines.length] if start < lines.length
|
|
77
|
+
ranges
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private def header_length(lines)
|
|
81
|
+
closing = comment_end(lines)
|
|
82
|
+
closing ? closing + 1 : 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The index of the line closing a comment that opens the file.
|
|
86
|
+
private def comment_end(lines)
|
|
87
|
+
lines.index { |line| line.include?("-->") } if lines.first&.include?("<!--")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private def code_blocks(lines)
|
|
91
|
+
document = MarkdownDocument.new(lines)
|
|
92
|
+
blocks = []
|
|
93
|
+
index = 0
|
|
94
|
+
while index < lines.length
|
|
95
|
+
closing = document.fence_end(index)
|
|
96
|
+
if closing
|
|
97
|
+
blocks << [index, closing]
|
|
98
|
+
index = closing
|
|
99
|
+
end
|
|
100
|
+
index += 1
|
|
101
|
+
end
|
|
102
|
+
blocks
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
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 "markdown_document"
|
|
9
|
+
require_relative "snippet"
|
|
10
|
+
require_relative "snippet_opening"
|
|
11
|
+
require_relative "wrapped_snippet"
|
|
12
|
+
|
|
13
|
+
# Fenced code blocks, and the code synced into a README between SYNC markers,
|
|
14
|
+
# which the wrapper goes around so syncing doesn't erase it.
|
|
15
|
+
class MarkdownSnippetSyntax # :nodoc:
|
|
16
|
+
LICENSE = "MIT-0"
|
|
17
|
+
SYNC_START = /<!--\s*SYNC:START/
|
|
18
|
+
SYNC_END = /<!--\s*SYNC:END/
|
|
19
|
+
|
|
20
|
+
def extensions
|
|
21
|
+
%w[md]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tracked_patterns
|
|
25
|
+
["*.md"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def license
|
|
29
|
+
LICENSE
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Without the wrappers that don't license their code MIT-0, so the code they
|
|
33
|
+
# held can be wrapped again, and with the copyright notices of the rest
|
|
34
|
+
# under the snippet tag.
|
|
35
|
+
def cleaned(lines)
|
|
36
|
+
_, removals = scan(lines, first_pass: true)
|
|
37
|
+
lines = lines.dup
|
|
38
|
+
removals.sort_by { |first, _| -first }.each do |first, last|
|
|
39
|
+
lines.delete_at(last)
|
|
40
|
+
(opening_end(lines, first) - first + 1).times { lines.delete_at(first) }
|
|
41
|
+
end
|
|
42
|
+
lines.each_index.select { |index| lines[index].include?("SPDX-SnippetBegin") }.each do |first|
|
|
43
|
+
first.upto(opening_end(lines, first)) { |index| lines[index] = Snippet.tag(lines[index]) }
|
|
44
|
+
end
|
|
45
|
+
lines
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def snippets_in(lines)
|
|
49
|
+
regions, = scan(lines, first_pass: false)
|
|
50
|
+
regions
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def opening(_snippet, copyrights)
|
|
54
|
+
# REUSE-IgnoreStart
|
|
55
|
+
[
|
|
56
|
+
"<!-- SPDX-SnippetBegin -->\n",
|
|
57
|
+
"<!--\n",
|
|
58
|
+
*copyrights.map { |copyright| " #{Snippet::COPYRIGHT_TAG}: #{copyright}\n" },
|
|
59
|
+
" SPDX-License-Identifier: #{LICENSE}\n",
|
|
60
|
+
"-->\n",
|
|
61
|
+
]
|
|
62
|
+
# REUSE-IgnoreEnd
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def closing(_snippet)
|
|
66
|
+
# REUSE-IgnoreStart
|
|
67
|
+
["<!-- SPDX-SnippetEnd -->\n"]
|
|
68
|
+
# REUSE-IgnoreEnd
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The last line of the opening wrapper at +first+. A SnippetBegin comment
|
|
72
|
+
# that closes on its own line is followed by the comment holding the SPDX
|
|
73
|
+
# tags, which goes with it.
|
|
74
|
+
private def opening_end(lines, first)
|
|
75
|
+
start = tags_follow?(lines, first) ? first + 1 : first
|
|
76
|
+
start.upto(lines.length - 1).find { |index| lines[index].include?("-->") } || (lines.length - 1)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private def tags_follow?(lines, first)
|
|
80
|
+
lines[first].include?("-->") && lines[first + 1]&.include?("<!--")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A wrapper from its SnippetBegin to its SnippetEnd, with the code between
|
|
84
|
+
# its opening tags and its end.
|
|
85
|
+
private def wrapped_region(lines, first, finish)
|
|
86
|
+
opening = SnippetOpening.in(lines, first, opening_end(lines, first))
|
|
87
|
+
return if opening.last + 1 > finish - 1
|
|
88
|
+
|
|
89
|
+
WrappedSnippet.new(snippet: Snippet.around(first, finish, code: [opening.last + 1, finish - 1]), opening:)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The first pass leaves MIT-0 wrappers alone and marks the rest for
|
|
93
|
+
# removal. The second, after removal, reports the wrappers left with their
|
|
94
|
+
# openings.
|
|
95
|
+
private def scan(lines, first_pass:)
|
|
96
|
+
document = MarkdownDocument.new(lines)
|
|
97
|
+
regions = []
|
|
98
|
+
removals = []
|
|
99
|
+
index = 0
|
|
100
|
+
while index < lines.length
|
|
101
|
+
if lines[index].include?("SPDX-SnippetBegin")
|
|
102
|
+
finish = index.upto(lines.length - 1).find { |later| lines[later].include?("SPDX-SnippetEnd") }
|
|
103
|
+
if !first_pass
|
|
104
|
+
wrapped = wrapped_region(lines, index, finish) if finish
|
|
105
|
+
regions << wrapped if wrapped
|
|
106
|
+
index = (finish || lines.length) + 1
|
|
107
|
+
next
|
|
108
|
+
elsif finish
|
|
109
|
+
removals << [index, finish] unless licensed?(lines, index)
|
|
110
|
+
index = finish + 1
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
region = sync_region(lines, document, index) || fence_region(document, index)
|
|
116
|
+
if region
|
|
117
|
+
regions << region
|
|
118
|
+
index = region.last + 1
|
|
119
|
+
else
|
|
120
|
+
index += 1
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
[regions, removals]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private def sync_region(lines, document, start)
|
|
127
|
+
return unless lines[start].match?(SYNC_START)
|
|
128
|
+
|
|
129
|
+
code_start = code_end = finish = nil
|
|
130
|
+
(start + 1).upto(lines.length - 1) do |later|
|
|
131
|
+
if document.fence?(later)
|
|
132
|
+
if code_start
|
|
133
|
+
code_end = later
|
|
134
|
+
else
|
|
135
|
+
code_start = later
|
|
136
|
+
end
|
|
137
|
+
elsif lines[later].match?(SYNC_END)
|
|
138
|
+
finish = later
|
|
139
|
+
break
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
Snippet.around(start, finish, code: [code_start, code_end]) if code_start && code_end && finish
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private def fence_region(document, start)
|
|
146
|
+
finish = document.fence_end(start)
|
|
147
|
+
Snippet.around(start, finish) if finish
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A wrapper that already credits someone under MIT-0 stays, whoever it
|
|
151
|
+
# credits.
|
|
152
|
+
private def licensed?(lines, begin_line)
|
|
153
|
+
tags = lines[(begin_line + 1)..].take_while { |line| !line.include?("-->") }
|
|
154
|
+
# REUSE-IgnoreStart
|
|
155
|
+
tags.any? { |line| line.include?(Snippet::COPYRIGHT_TAG) || line.include?(Snippet::FILE_COPYRIGHT_TAG) } &&
|
|
156
|
+
tags.any? { |line| line.include?(LICENSE) && line.include?("SPDX-License-Identifier") }
|
|
157
|
+
# REUSE-IgnoreEnd
|
|
158
|
+
end
|
|
159
|
+
end
|