licensure 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6759a51d14e8f88b4d718fb1368cf9e2593660e43863949d73bb929062be7c12
4
- data.tar.gz: 396ad8b96f81968fffab0dce383b368632ae6c12f71c8055589c3b361b6a0fb8
3
+ metadata.gz: 5887811c86a3c1c6981aa5ec6c9b1efd5ac9b3752a93eb1d588c6d62acb9d704
4
+ data.tar.gz: 53baba973dcbc133f942fbf251b30bc380a745b473aa235bddd06cc929eb0098
5
5
  SHA512:
6
- metadata.gz: 69a98b94fe4fe29946a48d8281ce993632db4be662e6cefb70cb5826d38807546a2dc8d4d1bf8598d63265ef4613b19b1797490b83efd84754d7a1a2a59f9a49
7
- data.tar.gz: df348e79ddac7e9f8bb783a6743a0706b1fac62de1100268fceaa51b895c80813ebd730d0edb734be0905a95d4b88fdc0b666670baefbb6225664bccd0f831ee
6
+ metadata.gz: fd784c7b336d63fc34f34e229757877a8fd9efabc392dd66930da6241fbf7be4a2e19f450eaaed47f4930b4cc8dc9994fd1320c510cf83736de2650729d06dca
7
+ data.tar.gz: ebded542cf8af7901a0e59a4ab1c92e8f3a4e7531167a99022a7ce8c3a409208cd53617f225b630c2f36d3f0aba0bb565f23b793cfe03bba964595496ad5b778
data/README.md CHANGED
@@ -56,10 +56,12 @@ ignored_gems:
56
56
  deny_unknown: true
57
57
  ```
58
58
 
59
- - `allowed_licenses`: Allowed license identifiers. Empty means allow all.
59
+ - `allowed_licenses`: Allowed license identifiers. Empty means allow all. For gems with multiple licenses, all reported licenses must be included.
60
60
  - `ignored_gems`: Gem names excluded from checks.
61
61
  - `deny_unknown`: Treat gems without license metadata as warnings.
62
62
 
63
+ When a gem reports non-SPDX license text and its `source_code_uri` or `homepage` points to GitHub, Licensure queries the GitHub repository license API and normalizes matched labels to `spdx_id` (for example, `Apache License, Version 2.0` -> `Apache-2.0`). Set `GITHUB_TOKEN` in CI to reduce API rate-limit risk.
64
+
63
65
  ## Commands
64
66
 
65
67
  ```bash
@@ -34,12 +34,13 @@ module Licensure
34
34
  next
35
35
  end
36
36
 
37
- if allowed_license?(info.licenses)
37
+ disallowed = disallowed_licenses(info.licenses)
38
+ if disallowed.empty?
38
39
  passed << info
39
40
  next
40
41
  end
41
42
 
42
- reason = "License '#{info.licenses.join(", ")}' is not in the allowed list"
43
+ reason = "Licenses '#{disallowed.join(", ")}' are not in the allowed list"
43
44
  violations << Violation.new(gem_info: info, reason: reason)
44
45
  end
45
46
 
@@ -49,9 +50,9 @@ module Licensure
49
50
  private
50
51
 
51
52
  # @param licenses [Array<String>]
52
- # @return [Boolean]
53
- def allowed_license?(licenses)
54
- licenses.any? { |license| @configuration.allowed_licenses.include?(license) }
53
+ # @return [Array<String>]
54
+ def disallowed_licenses(licenses)
55
+ licenses.reject { |license| @configuration.allowed_licenses.include?(license) }
55
56
  end
56
57
  end
57
58
  end
@@ -7,6 +7,7 @@ module Licensure
7
7
  # Fetches gem license data from local gemspecs and RubyGems API.
8
8
  class LicenseFetcher
9
9
  RUBYGEMS_ENDPOINT = "https://rubygems.org/api/v1/gems".freeze
10
+ GITHUB_LICENSE_ENDPOINT = "https://api.github.com/repos".freeze
10
11
  REQUEST_TIMEOUT = 5
11
12
 
12
13
  # @param dependencies [Array<Hash{Symbol => String}>]
@@ -20,16 +21,32 @@ module Licensure
20
21
  # @return [Licensure::GemLicenseInfo]
21
22
  def fetch(name, version)
22
23
  local = fetch_from_gemspec(name)
23
- return build_info(name, version, local[:licenses], :gemspec, local[:homepage]) if local
24
+ return build_info_from_payload(name, version, local, :gemspec) if local
24
25
 
25
26
  remote = fetch_from_api(name)
26
- return build_info(name, version, remote[:licenses], :api, remote[:homepage]) if remote
27
+ return build_info_from_payload(name, version, remote, :api) if remote
27
28
 
28
29
  build_info(name, version, [], :unknown, nil)
29
30
  end
30
31
 
31
32
  private
32
33
 
34
+ # @param name [String]
35
+ # @param version [String]
36
+ # @param payload [Hash]
37
+ # @param source [Symbol]
38
+ # @return [Licensure::GemLicenseInfo]
39
+ def build_info_from_payload(name, version, payload, source)
40
+ licenses = normalize_with_github(
41
+ payload[:licenses],
42
+ payload[:source_code_uri],
43
+ payload[:homepage]
44
+ )
45
+ homepage = payload[:homepage] || payload[:source_code_uri]
46
+
47
+ build_info(name, version, licenses, source, homepage)
48
+ end
49
+
33
50
  # @param name [String]
34
51
  # @return [Hash, nil]
35
52
  def fetch_from_gemspec(name)
@@ -37,7 +54,11 @@ module Licensure
37
54
  licenses = normalize_licenses(spec.licenses, spec.license)
38
55
  return nil if licenses.empty?
39
56
 
40
- { licenses: licenses, homepage: spec.homepage }
57
+ {
58
+ licenses: licenses,
59
+ homepage: spec.homepage,
60
+ source_code_uri: spec.metadata&.[]("source_code_uri")
61
+ }
41
62
  rescue Gem::LoadError
42
63
  nil
43
64
  end
@@ -56,7 +77,11 @@ module Licensure
56
77
  licenses = normalize_licenses(payload["licenses"], payload["license"])
57
78
  return nil if licenses.empty?
58
79
 
59
- { licenses: licenses, homepage: payload["homepage_uri"] || payload["homepage"] }
80
+ {
81
+ licenses: licenses,
82
+ homepage: payload["homepage_uri"] || payload["homepage"],
83
+ source_code_uri: payload["source_code_uri"]
84
+ }
60
85
  rescue JSON::ParserError, StandardError
61
86
  nil
62
87
  end
@@ -85,6 +110,109 @@ module Licensure
85
110
  .uniq
86
111
  end
87
112
 
113
+ # @param licenses [Array<String>]
114
+ # @param source_code_uri [String, nil]
115
+ # @param homepage [String, nil]
116
+ # @return [Array<String>]
117
+ def normalize_with_github(licenses, source_code_uri, homepage)
118
+ return licenses if licenses.empty?
119
+ return licenses unless github_normalization_needed?(licenses)
120
+
121
+ repository = github_repository(source_code_uri || homepage)
122
+ return licenses unless repository
123
+
124
+ github_license = fetch_github_license(repository[:owner], repository[:repo])
125
+ return licenses unless github_license
126
+
127
+ canonicalize_licenses(
128
+ licenses,
129
+ github_license[:spdx_id],
130
+ github_license[:name],
131
+ github_license[:key]
132
+ )
133
+ end
134
+
135
+ # @param licenses [Array<String>]
136
+ # @return [Boolean]
137
+ def github_normalization_needed?(licenses)
138
+ licenses.any? { |license| license.match?(/\s|,|\blicense\b|\bversion\b/i) }
139
+ end
140
+
141
+ # @param url [String, nil]
142
+ # @return [Hash{Symbol => String}, nil]
143
+ def github_repository(url)
144
+ return nil if url.to_s.strip.empty?
145
+
146
+ uri = URI(url)
147
+ host = uri.host.to_s.downcase
148
+ return nil unless %w[github.com www.github.com].include?(host)
149
+
150
+ segments = uri.path.to_s.split("/").reject(&:empty?)
151
+ return nil if segments.size < 2
152
+
153
+ owner = segments[0]
154
+ repo = segments[1].sub(/\.git\z/, "")
155
+ return nil if owner.empty? || repo.empty?
156
+
157
+ { owner: owner, repo: repo }
158
+ rescue URI::InvalidURIError
159
+ nil
160
+ end
161
+
162
+ # @param owner [String]
163
+ # @param repo [String]
164
+ # @return [Hash{Symbol => String}, nil]
165
+ def fetch_github_license(owner, repo)
166
+ uri = URI("#{GITHUB_LICENSE_ENDPOINT}/#{owner}/#{repo}/license")
167
+ request = Net::HTTP::Get.new(uri)
168
+ request["Accept"] = "application/vnd.github+json"
169
+ request["X-GitHub-Api-Version"] = "2022-11-28"
170
+ request["User-Agent"] = "Licensure/#{Licensure::VERSION}"
171
+
172
+ token = ENV["GITHUB_TOKEN"]
173
+ request["Authorization"] = "Bearer #{token}" unless token.to_s.empty?
174
+
175
+ response = http_client_for(uri).request(request)
176
+ return nil unless response.is_a?(Net::HTTPSuccess)
177
+
178
+ payload = JSON.parse(response.body)
179
+ license = payload["license"]
180
+ return nil unless license.is_a?(Hash)
181
+
182
+ spdx_id = license["spdx_id"].to_s.strip
183
+ return nil if spdx_id.empty? || spdx_id == "NOASSERTION"
184
+
185
+ { spdx_id: spdx_id, name: license["name"], key: license["key"] }
186
+ rescue JSON::ParserError, StandardError
187
+ nil
188
+ end
189
+
190
+ # @param licenses [Array<String>]
191
+ # @param spdx_id [String]
192
+ # @param name [String, nil]
193
+ # @param key [String, nil]
194
+ # @return [Array<String>]
195
+ def canonicalize_licenses(licenses, spdx_id, name, key)
196
+ fingerprints = [spdx_id, name, key].filter_map { |value| license_fingerprint(value) }.uniq
197
+ return licenses if fingerprints.empty?
198
+
199
+ licenses.map do |license|
200
+ fingerprint = license_fingerprint(license)
201
+ fingerprints.include?(fingerprint) ? spdx_id : license
202
+ end.uniq
203
+ end
204
+
205
+ # @param value [String, nil]
206
+ # @return [String, nil]
207
+ def license_fingerprint(value)
208
+ fingerprint = value.to_s.downcase
209
+ .gsub(/\b(the|license|version)\b/, "")
210
+ .gsub(/[^a-z0-9]/, "")
211
+ return nil if fingerprint.empty?
212
+
213
+ fingerprint
214
+ end
215
+
88
216
  # @param name [String]
89
217
  # @param version [String]
90
218
  # @param licenses [Array<String>]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Licensure
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: licensure
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada