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,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